Android: Split up the index.html for the UI in to html, css and js file (#349)

This commit is contained in:
NullVector 2026-10-05 10:29:39 +10:30 committed by GitHub
parent 6f2e5d9eb4
commit 35da0af29d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 1528 additions and 1530 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,588 @@
:root {
--bg: #ffffff;
--text: #111111;
--muted: #4a555c;
--line: rgba(128, 128, 128, 0.2);
--card: rgba(128, 128, 128, 0.1);
--band: #f2f5f7;
--accent: #4fc3f7;
--accent-text: #00344a;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #121212;
--text: #e8e8e8;
--muted: #b4bcc2;
--band: #1a2226;
--accent-text: #00344a;
}
}
html.dark {
--bg: #121212;
--text: #e8e8e8;
--muted: #b4bcc2;
--band: #1a2226;
}
html.light {
--bg: #ffffff;
--text: #111111;
--muted: #4a555c;
--band: #f2f5f7;
}
* {
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
html, body {
height: 100%;
background-color: var(--bg);
color: var(--text);
}
body {
margin: 0;
font-family: system-ui, sans-serif;
overscroll-behavior: none;
}
html.theme-ready {
transition: --bg 0.4s ease, --text 0.4s ease, --muted 0.4s ease, --line 0.4s ease, --card 0.4s ease, --band 0.4s ease,
background-color 0.4s ease, color 0.4s ease;
}
html.theme-ready body,
html.theme-ready .topbar,
html.theme-ready .topbar-back,
html.theme-ready .page,
html.theme-ready .setting-sub,
html.theme-ready .library-status,
html.theme-ready .empty-title,
html.theme-ready .empty-copy,
html.theme-ready .empty-hint,
html.theme-ready .browser-hint,
html.theme-ready .game-title,
html.theme-ready .page-title,
html.theme-ready .logo,
html.theme-ready .setting-label,
html.theme-ready .icon-btn,
html.theme-ready .settings-btn,
html.theme-ready .back-btn {
transition: background-color 0.4s ease, color 0.4s ease;
}
html.theme-ready .settings-item,
html.theme-ready .folder-item {
transition: background-color 0.4s ease, color 0.4s ease, border-color 0.4s ease;
}
html.theme-ready .settings-item:active,
html.theme-ready .folder-item:active {
transition-duration: 0.08s;
}
@media (prefers-reduced-motion: reduce) {
html.theme-ready,
html.theme-ready body,
html.theme-ready .topbar,
html.theme-ready .topbar-back,
html.theme-ready .page,
html.theme-ready .settings-item,
html.theme-ready .folder-item,
html.theme-ready .setting-sub,
html.theme-ready .library-status,
html.theme-ready .empty-title,
html.theme-ready .empty-copy,
html.theme-ready .empty-hint,
html.theme-ready .game-title,
html.theme-ready .page-title,
html.theme-ready .logo,
html.theme-ready .setting-label,
html.theme-ready .icon-btn,
html.theme-ready .settings-btn,
html.theme-ready .back-btn {
transition: none;
}
}
.page {
min-height: 100%;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8px 0 16px;
height: 56px;
position: sticky;
top: 0;
background: var(--band);
border-bottom: 1px solid var(--line);
z-index: 2;
}
.topbar-back {
display: flex;
align-items: center;
padding: 0 8px;
height: 56px;
position: sticky;
top: 0;
background: var(--band);
border-bottom: 1px solid var(--line);
z-index: 2;
}
.topbar-actions {
display: flex;
align-items: center;
}
.logo {
font-size: 22px;
font-weight: 700;
display: flex;
align-items: center;
gap: 8px;
}
.icon-btn {
width: 44px;
height: 44px;
border: none;
background: transparent;
color: var(--text);
display: flex;
align-items: center;
justify-content: center;
border-radius: 22px;
}
.icon-btn:active {
background: var(--card);
}
.icon-btn svg,
.settings-btn svg,
.back-btn svg,
.folder-remove svg {
width: 22px;
height: 22px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.settings-btn,
.back-btn {
width: 44px;
height: 44px;
border: none;
background: transparent;
color: var(--text);
display: flex;
align-items: center;
justify-content: center;
border-radius: 22px;
}
.settings-btn:active,
.back-btn:active {
background: var(--card);
}
.page-title {
font-size: 20px;
font-weight: 700;
}
.topbar-back .page-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.back-hint {
display: inline-flex;
align-items: center;
gap: 10px;
flex: none;
margin-left: 8px;
margin-right: 8px;
color: var(--muted);
font-size: 13px;
font-weight: 400;
line-height: 20px;
}
.hint-pair {
display: inline-flex;
align-items: center;
gap: 4px;
}
.back-hint[hidden] {
display: none;
}
.library-status {
padding: 0 16px 8px;
font-size: 13px;
color: var(--muted);
}
.library-status:empty {
display: none;
}
.settings-list {
padding: 8px 16px;
}
.settings-header {
font-size: 13px;
color: var(--muted);
padding: 20px 0 8px;
}
.settings-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 0;
border-bottom: 1px solid var(--line);
}
.settings-item:active {
background: var(--card);
margin: 0 -16px;
padding-left: 16px;
padding-right: 16px;
}
.settings-item.is-selected,
.folder-item.is-selected {
background: rgba(126, 246, 255, 0.16);
box-shadow: inset 3px 0 0 #7ef6ff;
margin: 0 -16px;
padding-left: 16px;
padding-right: 16px;
scroll-margin-top: 64px;
scroll-margin-bottom: 24px;
}
.primary-btn.is-selected,
.fab.is-selected {
outline: 3px solid #7ef6ff;
outline-offset: 2px;
}
.setting-chevron {
width: 18px;
height: 18px;
flex-shrink: 0;
opacity: 0.45;
}
.settings-toggle {
width: 44px;
height: 26px;
border-radius: 13px;
border: none;
background: var(--line);
position: relative;
flex-shrink: 0;
}
.settings-toggle.on {
background: var(--accent);
}
.settings-toggle::after {
content: '';
position: absolute;
top: 3px;
left: 3px;
width: 20px;
height: 20px;
border-radius: 10px;
background: #fff;
transition: left 0.15s ease;
}
.settings-toggle.on::after {
left: 21px;
}
.settings-range {
width: 100%;
margin-top: 8px;
}
.setting-item-static:active {
background: transparent;
margin: 0;
padding-left: 0;
padding-right: 0;
}
.setting-check {
width: 22px;
height: 22px;
flex-shrink: 0;
color: var(--accent);
opacity: 0;
}
.setting-check.on {
opacity: 1;
}
.fab {
position: fixed;
bottom: 24px;
right: 20px;
width: 56px;
height: 56px;
border-radius: 16px;
border: none;
background: var(--accent);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
}
.fab svg {
width: 24px;
height: 24px;
fill: none;
stroke: currentColor;
stroke-width: 2.5;
stroke-linecap: round;
}
.folder-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 0;
border-bottom: 1px solid var(--line);
}
.setting-info {
flex: 1;
min-width: 0;
}
.setting-label {
font-size: 16px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.setting-sub {
font-size: 13px;
color: var(--muted);
margin-top: 4px;
}
.folder-remove {
width: 44px;
height: 44px;
border: none;
background: transparent;
color: var(--text);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: 22px;
}
.folder-remove:active {
background: var(--card);
}
.folder-remove svg {
fill: currentColor;
stroke: none;
}
.empty-hint {
padding: 40px 16px;
text-align: center;
color: var(--muted);
font-size: 14px;
}
.empty-state {
padding: 48px 24px 96px;
text-align: center;
}
.empty-title {
font-size: 18px;
font-weight: 650;
margin: 0 0 8px;
color: var(--text);
}
.empty-copy {
font-size: 14px;
color: var(--muted);
margin: 0 0 20px;
line-height: 1.45;
}
.primary-btn {
border: none;
background: var(--accent);
color: var(--accent-text);
font-size: 15px;
font-weight: 650;
padding: 12px 18px;
border-radius: 12px;
}
.primary-btn:active {
opacity: 0.85;
}
.link-btn {
border: none;
background: transparent;
color: var(--accent);
font-size: 14px;
font-weight: 650;
padding: 8px 8px 0;
}
.game-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(112px, 1fr));
gap: 14px 10px;
padding: 8px 16px 96px;
}
.browser-hint {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 2;
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
gap: 4px 14px;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
background: var(--band);
border-top: 1px solid var(--line);
color: var(--muted);
font-size: 13px;
line-height: 20px;
text-align: center;
}
.browser-hint > span {
display: inline-flex;
align-items: center;
gap: 6px;
}
.browser-hint > span[hidden] {
display: none;
}
.hint-key {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
flex: none;
border: none;
border-radius: 10px;
background: var(--text);
color: var(--band);
font-size: 11px;
font-weight: 700;
line-height: 1;
}
.hint-action {
color: var(--text);
font-weight: 600;
}
.game-card {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.game-card:active {
opacity: 0.7;
}
.game-card.is-selected .game-icon,
.game-card.is-selected .game-icon-ph {
outline: 3px solid #7ef6ff;
outline-offset: 2px;
box-shadow: 0 0 10px #7ef6ff;
}
.game-card.is-failed {
display: none;
}
.game-icon,
.game-icon-ph {
width: 100%;
aspect-ratio: 1;
border-radius: 12px;
object-fit: contain;
background: var(--card);
display: block;
}
.game-icon-ph {
animation: pulse 1.2s ease-in-out infinite;
}
.game-title {
font-size: 13px;
font-weight: 600;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-word;
}
.scan-hint {
grid-column: 1 / -1;
color: var(--muted);
font-size: 13px;
padding: 4px 0 8px;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.45; }
}

View File

@ -0,0 +1,938 @@
let navStack = ['gamesPage'];
let gameListGen = 0;
let libraryDirty = true;
let selectedGameIndex = -1;
let settingsSelection = -1;
const settingsSelectionByPage = {};
let themeReady = false;
function applyTheme() {
let dark = false;
try {
dark = !!NxEmu.isDarkTheme();
} catch (e) {
dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
}
document.documentElement.classList.toggle('dark', dark);
document.documentElement.classList.toggle('light', !dark);
if (!themeReady) {
themeReady = true;
requestAnimationFrame(function () {
requestAnimationFrame(function () {
document.documentElement.classList.add('theme-ready');
});
});
}
}
function onSettingChanged(setting) {
if (setting === 'nxui:GameDirectories') {
libraryDirty = true;
renderGameFolders();
renderSettingsSummary();
if (navStack[navStack.length - 1] === 'gamesPage') {
refreshGameList();
}
}
if (setting === 'nxui:ThemeMode') {
applyTheme();
renderThemeSummary();
renderThemeSettings();
}
}
function handleAndroidBack() {
if (navStack.length > 1) {
goBack();
return true;
}
return false;
}
function escapeHtml(text) {
if (!text) return '';
return String(text)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function iconMimeFromBase64(b64) {
if (!b64 || b64.length < 4) return 'image/jpeg';
try {
const head = atob(b64.slice(0, 8));
const c0 = head.charCodeAt(0);
const c1 = head.charCodeAt(1);
if (c0 === 0xff && c1 === 0xd8) return 'image/jpeg';
if (head.startsWith('GIF')) return 'image/gif';
if (head.charCodeAt(0) === 0x89 && head.slice(1, 4) === 'PNG') return 'image/png';
} catch (e) {}
return 'image/jpeg';
}
function getConfiguredFolders() {
try {
const dirs = JSON.parse(NxEmu.getSettingString('nxui:GameDirectories') || '[]');
return Array.isArray(dirs) ? dirs : [];
} catch (e) {
return [];
}
}
function setLibraryStatus(text) {
const status = document.getElementById('libraryStatus');
if (status) status.textContent = text || '';
}
function renderEmptyGames(kind) {
const container = document.getElementById('gameList');
syncGameSelection(-1);
container.className = '';
if (kind === 'no-folders') {
container.innerHTML =
'<div class="empty-state">' +
'<p class="empty-title">Add a game folder</p>' +
'<p class="empty-copy">NxEmu lists games from folders you choose. Your files stay where they are.</p>' +
'<button class="primary-btn" onclick="NxEmu.addGameDirectory()">Add game folder</button>' +
'</div>';
return;
}
if (kind === 'no-roms') {
container.innerHTML =
'<div class="empty-state">' +
'<p class="empty-title">No games found</p>' +
'<p class="empty-copy">No .nro, .dxci, or .dnsp files were found in your folders.</p>' +
'<button class="primary-btn" onclick="navigate(\'gameFolders\')">Manage folders</button>' +
'</div>';
return;
}
container.innerHTML =
'<div class="empty-state">' +
'<p class="empty-title">Couldn\'t read these files</p>' +
'<p class="empty-copy">Files were found, but none could be opened as games. Check that they are valid dumps.</p>' +
'<button class="link-btn" onclick="refreshGameList()">Try again</button>' +
'</div>';
}
function refreshGameList() {
libraryDirty = false;
const gen = ++gameListGen;
const container = document.getElementById('gameList');
if (!container) return;
syncGameSelection(-1);
const folders = getConfiguredFolders();
if (!folders.length) {
setLibraryStatus('');
renderEmptyGames('no-folders');
return;
}
container.className = '';
container.innerHTML = '<div class="empty-hint">Scanning folders...</div>';
setLibraryStatus('Scanning...');
NxEmu.requestGameLibraryScan(gen);
}
function onGameLibraryPaths(gen, jsonStr) {
if (gen !== gameListGen) return;
const container = document.getElementById('gameList');
if (!container) return;
let paths = [];
try {
paths = JSON.parse(jsonStr);
} catch (e) {
paths = [];
}
if (!Array.isArray(paths)) paths = [];
if (!paths.length) {
setLibraryStatus('');
renderEmptyGames('no-roms');
return;
}
container.className = 'game-list';
container.innerHTML = '';
const cards = [];
for (let i = 0; i < paths.length; i++) {
const path = paths[i];
const card = document.createElement('div');
card.className = 'game-card is-loading';
card.innerHTML =
'<div class="game-icon-ph"></div>' +
'<div class="game-title">' + escapeHtml(shortPath(path)) + '</div>';
container.appendChild(card);
cards.push({ path: path, el: card });
}
setLibraryStatus('Loading 0 of ' + paths.length);
loadGameMetadata(gen, cards, 0, 0, 0);
}
function fillGameCard(card, meta, path) {
const title = meta.title && meta.title.length ? meta.title : shortPath(path);
const iconSrc = meta.icon && meta.icon.length
? ('data:' + iconMimeFromBase64(meta.icon) + ';base64,' + meta.icon)
: 'file:///android_asset/nxlogo-32x32.png';
card.className = 'game-card';
card.innerHTML =
'<img class="game-icon" src="' + iconSrc + '" alt="">' +
'<div class="game-title">' + escapeHtml(title) + '</div>';
card.dataset.title = title.toLowerCase();
card.onclick = function () {
try {
NxEmu.launchGame(path);
} catch (e) {}
};
}
async function loadGameMetadata(gen, cards, index, loaded, failed) {
if (gen !== gameListGen) return;
if (index >= cards.length) {
finishGameList(gen, cards, loaded, failed);
return;
}
setLibraryStatus('Loading ' + (index + 1) + ' of ' + cards.length);
const item = cards[index];
let meta = { title: '', icon: '', error: 'failed', path: item.path };
try {
meta = JSON.parse(NxEmu.queryRomMetadata(item.path));
} catch (e) {
meta.error = 'parse';
}
if (meta && !meta.error) {
fillGameCard(item.el, meta, item.path);
loaded += 1;
} else {
item.el.className = 'game-card is-failed';
failed += 1;
}
await new Promise(function (resolve) { setTimeout(resolve, 0); });
loadGameMetadata(gen, cards, index + 1, loaded, failed);
}
function finishGameList(gen, cards, loaded, failed) {
if (gen !== gameListGen) return;
const container = document.getElementById('gameList');
if (!container) return;
if (!loaded) {
setLibraryStatus('');
renderEmptyGames('unreadable');
return;
}
const visible = Array.prototype.slice.call(container.querySelectorAll('.game-card:not(.is-failed)'));
visible.sort(function (a, b) {
return (a.dataset.title || '').localeCompare(b.dataset.title || '');
});
visible.forEach(function (el) { container.appendChild(el); });
setLibraryStatus('');
syncGameSelection(0);
}
function selectableGameCards() {
const container = document.getElementById('gameList');
if (!container) return [];
return Array.prototype.filter.call(container.children, function (el) {
return el.classList.contains('game-card') &&
!el.classList.contains('is-failed') &&
!el.classList.contains('is-loading');
});
}
function controllerAttached() {
try {
const names = JSON.parse(NxEmu.connectedControllerNames());
return Array.isArray(names) && names.length > 0;
} catch (e) {
return false;
}
}
function updatePlayHint() {
const hint = document.getElementById('playHint');
if (hint) hint.hidden = !controllerAttached() || selectedGameIndex < 0;
}
function applyControllerBrowser() {
const attached = controllerAttached();
const settingsHint = document.getElementById('settingsHint');
const touchHint = document.getElementById('touchHint');
if (settingsHint) settingsHint.hidden = !attached;
if (touchHint) touchHint.hidden = attached;
document.querySelectorAll('.back-hint').forEach(function (hint) {
hint.hidden = !attached;
});
if (!attached) {
syncGameSelection(-1);
syncSettingsSelection(-1);
return;
}
if (currentSettingsPage()) {
const page = currentSettingsPage();
const remembered = settingsSelectionByPage[page.id];
const index = settingsSelection >= 0 ? settingsSelection : (remembered == null ? 0 : remembered);
syncSettingsSelection(index);
}
if (selectedGameIndex < 0 && selectableGameCards().length) {
syncGameSelection(0);
} else {
updatePlayHint();
}
}
function syncGameSelection(index) {
const cards = selectableGameCards();
if (!controllerAttached() || !cards.length || index < 0) {
selectedGameIndex = -1;
} else {
selectedGameIndex = Math.min(index, cards.length - 1);
cards[selectedGameIndex].scrollIntoView({ block: 'nearest', inline: 'nearest' });
}
for (let i = 0; i < cards.length; i++) {
cards[i].classList.toggle('is-selected', i === selectedGameIndex);
}
updatePlayHint();
}
function gridColumns(cards) {
const top = cards[0].getBoundingClientRect().top;
let cols = 0;
for (let i = 0; i < cards.length; i++) {
if (Math.abs(cards[i].getBoundingClientRect().top - top) > 4) break;
cols++;
}
return cols || 1;
}
function moveGameSelection(direction) {
if (!controllerAttached()) return;
if (navStack[navStack.length - 1] !== 'gamesPage') {
moveSettingsSelection(direction);
return;
}
const cards = selectableGameCards();
if (!cards.length) return;
if (selectedGameIndex < 0) {
syncGameSelection(0);
return;
}
const cols = gridColumns(cards);
let next = selectedGameIndex;
if (direction === 'left') next -= 1;
else if (direction === 'right') next += 1;
else if (direction === 'up') next -= cols;
else if (direction === 'down') next += cols;
if (next < 0) next = 0;
if (next >= cards.length) next = cards.length - 1;
syncGameSelection(next);
}
function launchSelectedGame() {
if (!controllerAttached()) return;
if (navStack[navStack.length - 1] !== 'gamesPage') {
activateSettingsSelection();
return;
}
const card = selectableGameCards()[selectedGameIndex];
if (card && typeof card.onclick === 'function') card.onclick();
}
function shortPath(uri) {
try {
const u = decodeURIComponent(uri);
const last = u.lastIndexOf('/');
return last >= 0 ? u.slice(last + 1) : u;
} catch (e) {
return uri;
}
}
function openSettingsFromPad() {
if (navStack[navStack.length - 1] !== 'gamesPage') return;
navigate('settingsPage');
}
function goBackFromPad() {
if (!controllerAttached()) return;
if (navStack[navStack.length - 1] === 'gamesPage') return;
goBack();
}
function currentSettingsPage() {
const id = navStack[navStack.length - 1];
if (!id || id === 'gamesPage') return null;
const page = document.getElementById(id);
if (!page || page.style.display === 'none') return null;
return page;
}
function settingsRows(page) {
if (!page) return [];
return Array.prototype.filter.call(
page.querySelectorAll('.settings-item, .folder-item, .primary-btn, .fab'),
function (el) { return el.getClientRects().length > 0; }
);
}
function clearSettingsSelection() {
document.querySelectorAll('.settings-item.is-selected, .folder-item.is-selected, .primary-btn.is-selected, .fab.is-selected').forEach(function (el) {
el.classList.remove('is-selected');
});
}
function syncSettingsSelection(index) {
const page = currentSettingsPage();
const rows = settingsRows(page);
clearSettingsSelection();
if (!controllerAttached() || !page || !rows.length || index < 0) {
settingsSelection = -1;
return;
}
settingsSelection = Math.min(index, rows.length - 1);
settingsSelectionByPage[page.id] = settingsSelection;
rows[settingsSelection].classList.add('is-selected');
rows[settingsSelection].scrollIntoView({ block: 'nearest', inline: 'nearest' });
}
function refreshSettingsSelection() {
if (!currentSettingsPage()) return;
syncSettingsSelection(settingsSelection < 0 ? 0 : settingsSelection);
}
function moveSettingsSelection(direction) {
if (!controllerAttached() || !currentSettingsPage()) return;
const rows = settingsRows(currentSettingsPage());
if (!rows.length) return;
if (direction === 'left' || direction === 'right') {
nudgeSelectedSlider(direction);
return;
}
if (settingsSelection < 0) {
syncSettingsSelection(0);
return;
}
let next = settingsSelection + (direction === 'up' ? -1 : 1);
if (next < 0) next = 0;
if (next >= rows.length) next = rows.length - 1;
syncSettingsSelection(next);
}
function nudgeSelectedSlider(direction) {
const row = settingsRows(currentSettingsPage())[settingsSelection];
if (!row) return;
const input = row.querySelector('input[type="range"]');
if (!input) return;
const min = parseFloat(input.min);
const max = parseFloat(input.max);
const stepAttr = parseFloat(input.step);
const span = max - min;
const step = stepAttr >= 1 ? Math.max(stepAttr, 1) : (span > 0 ? span / 20 : 0.05);
let value = parseFloat(input.value) + (direction === 'right' ? step : -step);
if (stepAttr >= 1) value = Math.round(value / stepAttr) * stepAttr;
value = Math.min(max, Math.max(min, value));
input.value = String(value);
input.dispatchEvent(new Event('input', { bubbles: true }));
}
function activateSettingsSelection() {
if (!controllerAttached() || !currentSettingsPage()) return;
const row = settingsRows(currentSettingsPage())[settingsSelection];
if (!row || row.querySelector('input[type="range"]')) return;
if (row.classList.contains('folder-item')) {
const remove = row.querySelector('.folder-remove');
if (remove) remove.click();
return;
}
row.click();
}
function navigate(page) {
navStack.push(page);
showPage(page);
}
function goBack() {
if (navStack.length > 1) {
navStack.pop();
showPage(navStack[navStack.length - 1]);
}
}
function formatGameFolder(uri) {
const decoded = decodeURIComponent(uri);
const prefix = 'content://com.android.externalstorage.documents/tree/';
if (!decoded.startsWith(prefix)) return decoded;
const path = decoded.substring(prefix.length);
const colonIndex = path.indexOf(':');
const volume = path.substring(0, colonIndex);
const folder = path.substring(colonIndex + 1);
if (volume === 'primary') {
return 'Internal storage / ' + folder;
}
return volume + ' / ' + folder;
}
function renderSettingsSummary() {
const meta = document.getElementById('gameFoldersMeta');
if (meta) {
const n = getConfiguredFolders().length;
if (n === 0) meta.textContent = 'None added';
else if (n === 1) meta.textContent = '1 folder';
else meta.textContent = n + ' folders';
}
renderThemeSummary();
renderOverlaySummary();
renderControllersSummary();
}
function renderControllersSummary() {
const meta = document.getElementById('controllersMeta');
if (meta) {
var count = 0;
for (var player = 0; player < 8; player++) {
if (NxEmu.isControllerConnected(player)) count++;
}
meta.textContent = count === 0 ? 'Connected: none' : 'Connected: ' + count;
}
applyControllerBrowser();
}
function getThemeMode() {
try {
const mode = NxEmu.getSettingInt('nxui:ThemeMode');
if (mode === 1 || mode === 2) return mode;
} catch (e) {}
return 0;
}
function themeModeLabel(mode) {
if (mode === 1) return 'Light';
if (mode === 2) return 'Dark';
return 'Follow system';
}
function renderThemeSummary() {
const meta = document.getElementById('themeModeMeta');
if (meta) meta.textContent = themeModeLabel(getThemeMode());
}
function renderOverlaySummary() {
const meta = document.getElementById('overlayShowMeta');
if (!meta) return;
let shown = true;
try {
shown = NxEmu.getSettingBool('nxui:ShowInputOverlay');
} catch (e) {}
meta.textContent = 'Show overlay: ' + (shown ? 'Yes' : 'No');
}
function renderThemeSettings() {
const mode = getThemeMode();
setToggle('themeFollow', mode === 0);
setToggle('themeLight', mode === 1);
setToggle('themeDark', mode === 2);
}
function setThemeMode(mode) {
NxEmu.setSettingInt('nxui:ThemeMode', mode);
NxEmu.saveSettings();
applyTheme();
renderThemeSummary();
renderThemeSettings();
}
function setToggle(id, on) {
const el = document.getElementById(id);
if (!el) return;
el.classList.toggle('on', !!on);
}
function renderOverlaySettings() {
setToggle('overlayShow', NxEmu.getSettingBool('nxui:ShowInputOverlay'));
const scale = NxEmu.getSettingInt('nxui:OverlayScale');
const opacity = NxEmu.getSettingInt('nxui:OverlayOpacity');
const scaleEl = document.getElementById('overlayScale');
const opacityEl = document.getElementById('overlayOpacity');
if (scaleEl) scaleEl.value = scale;
if (opacityEl) opacityEl.value = opacity;
const scaleValue = document.getElementById('overlayScaleValue');
const opacityValue = document.getElementById('overlayOpacityValue');
if (scaleValue) scaleValue.textContent = scale + '%';
if (opacityValue) opacityValue.textContent = opacity + '%';
}
function toggleOverlayBool(key, toggleId) {
const next = !NxEmu.getSettingBool(key);
NxEmu.setSettingBool(key, next);
NxEmu.saveSettings();
setToggle(toggleId, next);
if (key === 'nxui:ShowInputOverlay') renderOverlaySummary();
}
function setOverlayInt(key, value, labelId) {
const n = parseInt(value, 10);
NxEmu.setSettingInt(key, n);
NxEmu.saveSettings();
const label = document.getElementById(labelId);
if (label) label.textContent = n + '%';
}
function renderGameFolders() {
const list = document.getElementById('folderList');
const empty = document.getElementById('noFolders');
if (!list || !empty) return;
const dirs = getConfiguredFolders();
empty.style.display = dirs.length === 0 ? '' : 'none';
list.innerHTML = '';
const fab = document.querySelector('#gameFolders .fab');
if (fab) fab.style.display = dirs.length === 0 ? 'none' : '';
dirs.forEach(function (uri) {
const name = formatGameFolder(uri);
const item = document.createElement('div');
item.className = 'folder-item';
item.innerHTML =
'<div class="setting-info">' +
'<div class="setting-label">' + escapeHtml(name) + '</div>' +
'</div>' +
'<button class="folder-remove" type="button" aria-label="Remove folder">' +
'<svg viewBox="0 0 1024 1024"><path d="M160 256H96a32 32 0 0 1 0-64h256V95.936a32 32 0 0 1 32-32h256a32 32 0 0 1 32 32V192h256a32 32 0 1 1 0 64h-64v672a32 32 0 0 1-32 32H192a32 32 0 0 1-32-32zm448-64v-64H416v64zM224 896h576V256H224zm192-128a32 32 0 0 1-32-32V416a32 32 0 0 1 64 0v320a32 32 0 0 1-32 32m192 0a32 32 0 0 1-32-32V416a32 32 0 0 1 64 0v320a32 32 0 0 1-32 32"/></svg>' +
'</button>';
item.querySelector('.folder-remove').addEventListener('click', function () {
removeGameFolder(uri);
});
list.appendChild(item);
});
refreshSettingsSelection();
}
function removeGameFolder(uri) {
if (!confirm('Remove this folder from the library? The files will not be deleted.')) {
return;
}
const dirs = getConfiguredFolders().filter(function (d) { return d !== uri; });
NxEmu.setSettingString('nxui:GameDirectories', JSON.stringify(dirs));
NxEmu.saveSettings();
}
var controllerPlayerIndex = 0;
var controllerFilterIndex = 0;
var BTN = { A: 0, B: 1, X: 2, Y: 3, LStick: 4, RStick: 5, L: 6, R: 7, ZL: 8, ZR: 9, Plus: 10, Minus: 11, DLeft: 12, DUp: 13, DRight: 14, DDown: 15, SLLeft: 16, SRLeft: 17, Home: 18, Capture: 19, SLRight: 20, SRRight: 21 };
var STICK = { L: 0, R: 1 };
function controllerStyleName(style) {
if (style === 3) return 'Pro Controller';
if (style === 4) return 'Handheld';
if (style === 5) return 'Dual Joycons';
if (style === 6) return 'Left Joycon';
if (style === 7) return 'Right Joycon';
if (style === 8) return 'GameCube Controller';
if (style === 0) return 'None';
return 'Unknown';
}
function controllerChevron() {
return '<svg class="setting-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l6-6-6-6"/></svg>';
}
function parseJsonArray(text) {
try {
const parsed = JSON.parse(text);
return Array.isArray(parsed) ? parsed : [];
} catch (e) {
return [];
}
}
function openControllerPlayer(index) {
controllerPlayerIndex = index;
controllerFilterIndex = 0;
navigate('controllerPlayer');
}
function renderControllerPages() {
const listPage = document.getElementById('controllerSettings');
const playerPage = document.getElementById('controllerPlayer');
if (listPage && listPage.style.display !== 'none') renderControllerSettings();
if (playerPage && playerPage.style.display !== 'none') renderControllerPlayer();
}
function renderControllerSettings() {
const list = document.getElementById('controllerList');
if (!list) return;
list.innerHTML = '';
for (var player = 0; player < 8; player++) {
const connected = NxEmu.isControllerConnected(player);
const styleName = controllerStyleName(NxEmu.getControllerStyle(player));
const item = document.createElement('div');
item.className = 'settings-item';
item.innerHTML =
'<div class="setting-info">' +
'<div class="setting-label">Controller ' + (player + 1) + '</div>' +
'<div class="setting-sub">' + escapeHtml(connected ? ('Connected - ' + styleName) : 'Disconnected') + '</div>' +
'</div>' + controllerChevron();
item.addEventListener('click', openControllerPlayer.bind(null, player));
list.appendChild(item);
}
refreshSettingsSelection();
}
function appendControllerHeader(list, title) {
const header = document.createElement('div');
header.className = 'settings-header';
header.textContent = title;
list.appendChild(header);
}
function appendControllerSwitch(list, title, checked, onClick) {
const item = document.createElement('div');
item.className = 'settings-item';
item.innerHTML =
'<div class="setting-info"><div class="setting-label">' + escapeHtml(title) + '</div></div>' +
'<button class="settings-toggle' + (checked ? ' on' : '') + '" type="button" aria-label="' + escapeHtml(title) + '"></button>';
item.addEventListener('click', onClick);
list.appendChild(item);
}
function appendControllerRow(list, title, subtitle, onClick) {
const item = document.createElement('div');
item.className = 'settings-item';
item.innerHTML =
'<div class="setting-info">' +
'<div class="setting-label">' + escapeHtml(title) + '</div>' +
(subtitle ? '<div class="setting-sub">' + escapeHtml(subtitle) + '</div>' : '') +
'</div>' + controllerChevron();
item.addEventListener('click', onClick);
list.appendChild(item);
}
function appendControllerMap(list, title, mapId) {
const subtitle = mapId.indexOf('button:') === 0
? NxEmu.getButtonBinding(controllerPlayerIndex, parseInt(mapId.slice(7), 10))
: NxEmu.getStickBinding(controllerPlayerIndex, stickIdFromMap(mapId), stickPartFromMap(mapId));
appendControllerRow(list, title, subtitle, function () {
NxEmu.beginControllerMap(controllerPlayerIndex, mapId, title, controllerFilterIndex);
});
}
function stickIdFromMap(mapId) {
return parseInt(mapId.split(':')[1], 10);
}
function stickPartFromMap(mapId) {
const parts = mapId.split(':');
return parts[0] === 'modifier' ? 'modifier' : parts[2];
}
function appendControllerSlider(list, title, stick, key, min, max) {
const value = NxEmu.getStickValue(controllerPlayerIndex, stick, key);
const item = document.createElement('div');
item.className = 'settings-item setting-item-static';
item.innerHTML =
'<div class="setting-info" style="flex:1">' +
'<div class="setting-label">' + escapeHtml(title) + ' <span class="slider-value">' + Math.round(value * 100) + '%</span></div>' +
'<input class="settings-range" type="range" min="' + min + '" max="' + max + '" step="0.01" value="' + value + '">' +
'</div>';
const input = item.querySelector('input');
const label = item.querySelector('.slider-value');
input.addEventListener('input', function () {
const next = parseFloat(input.value);
if (label) label.textContent = Math.round(next * 100) + '%';
NxEmu.setStickValue(controllerPlayerIndex, stick, key, next);
});
list.appendChild(item);
}
function appendStickDirections(list, stick) {
appendControllerMap(list, 'Up', 'analog:' + stick + ':up');
appendControllerMap(list, 'Down', 'analog:' + stick + ':down');
appendControllerMap(list, 'Left', 'analog:' + stick + ':left');
appendControllerMap(list, 'Right', 'analog:' + stick + ':right');
}
function appendStickExtras(list, stick) {
if (NxEmu.isControllerStick(controllerPlayerIndex, stick)) {
appendControllerSlider(list, 'Range', stick, 'range', 0.25, 1.5);
appendControllerSlider(list, 'Deadzone', stick, 'deadzone', 0, 1);
} else {
appendControllerMap(list, 'Modifier', 'modifier:' + stick);
appendControllerSlider(list, 'Modifier range', stick, 'modifier_scale', 0, 1);
}
}
function appendButtons(list, buttons) {
buttons.forEach(function (button) {
appendControllerMap(list, button[0], 'button:' + button[1]);
});
}
function renderControllerPlayer() {
const title = document.getElementById('controllerPlayerTitle');
const list = document.getElementById('controllerPlayerList');
if (!list) return;
const player = controllerPlayerIndex;
const style = NxEmu.getControllerStyle(player);
const filters = parseJsonArray(NxEmu.getControllerFilterNames());
if (controllerFilterIndex >= filters.length) controllerFilterIndex = 0;
if (title) title.textContent = 'Controller ' + (player + 1);
list.innerHTML = '';
appendControllerSwitch(list, 'Connected', NxEmu.isControllerConnected(player), function () {
NxEmu.setControllerConnected(player, !NxEmu.isControllerConnected(player));
renderControllerPlayer();
});
appendControllerRow(list, 'Controller type', controllerStyleName(style), openControllerStyles);
appendControllerRow(list, 'Auto-map a controller', 'Select a device to attempt auto-mapping', openAutoMap);
appendControllerRow(list, 'Input mapping filter', filters[controllerFilterIndex] || 'Unknown', openControllerFilter);
appendControllerRow(list, 'Reset to default', '', confirmResetController);
const full = style === 3 || style === 4 || style === 5;
const left = style === 6;
const right = style === 7;
const gamecube = style === 8;
if (full) {
appendControllerHeader(list, 'Buttons');
appendButtons(list, [['A', BTN.A], ['B', BTN.B], ['X', BTN.X], ['Y', BTN.Y], ['Plus', BTN.Plus], ['Minus', BTN.Minus], ['Home', BTN.Home], ['Capture', BTN.Capture]]);
} else if (left) {
appendControllerHeader(list, 'Buttons');
appendButtons(list, [['Minus', BTN.Minus], ['Capture', BTN.Capture]]);
} else if (right) {
appendControllerHeader(list, 'Buttons');
appendButtons(list, [['A', BTN.A], ['B', BTN.B], ['X', BTN.X], ['Y', BTN.Y], ['Plus', BTN.Plus], ['Home', BTN.Home]]);
} else if (gamecube) {
appendControllerHeader(list, 'Buttons');
appendButtons(list, [['A', BTN.A], ['B', BTN.B], ['X', BTN.X], ['Y', BTN.Y], ['Start/Pause', BTN.Plus]]);
}
if (full || left) {
appendControllerHeader(list, 'D-Pad');
appendButtons(list, [['Up', BTN.DUp], ['Down', BTN.DDown], ['Left', BTN.DLeft], ['Right', BTN.DRight]]);
}
if (full || left) {
appendControllerHeader(list, 'Left stick');
appendStickDirections(list, STICK.L);
appendButtons(list, [['Pressed', BTN.LStick]]);
appendStickExtras(list, STICK.L);
} else if (gamecube) {
appendControllerHeader(list, 'Control stick');
appendStickDirections(list, STICK.L);
appendStickExtras(list, STICK.L);
}
if (full || right) {
appendControllerHeader(list, 'Right stick');
appendStickDirections(list, STICK.R);
appendButtons(list, [['Pressed', BTN.RStick]]);
appendStickExtras(list, STICK.R);
} else if (gamecube) {
appendControllerHeader(list, 'C-Stick');
appendStickDirections(list, STICK.R);
appendStickExtras(list, STICK.R);
}
if (style === 3 || style === 4) {
appendControllerHeader(list, 'Triggers');
appendButtons(list, [['L', BTN.L], ['R', BTN.R], ['ZL', BTN.ZL], ['ZR', BTN.ZR]]);
} else if (style === 5) {
appendControllerHeader(list, 'Triggers');
appendButtons(list, [['L', BTN.L], ['R', BTN.R], ['ZL', BTN.ZL], ['ZR', BTN.ZR], ['Left SL', BTN.SLLeft], ['Left SR', BTN.SRLeft], ['Right SL', BTN.SLRight], ['Right SR', BTN.SRRight]]);
} else if (left) {
appendControllerHeader(list, 'Triggers');
appendButtons(list, [['L', BTN.L], ['ZL', BTN.ZL], ['Left SL', BTN.SLLeft], ['Left SR', BTN.SRLeft]]);
} else if (right) {
appendControllerHeader(list, 'Triggers');
appendButtons(list, [['R', BTN.R], ['ZR', BTN.ZR], ['Right SL', BTN.SLRight], ['Right SR', BTN.SRRight]]);
} else if (gamecube) {
appendControllerHeader(list, 'Triggers');
appendButtons(list, [['Z', BTN.R], ['L', BTN.ZL], ['R', BTN.ZR]]);
}
refreshSettingsSelection();
}
function openControllerChoices(title, items, onPick, emptyText) {
const choiceList = document.getElementById('controllerChoiceList');
document.getElementById('controllerChoicesTitle').textContent = title;
if (choiceList) choiceList.style.display = '';
choiceList.innerHTML = '';
if (!items.length) {
const empty = document.createElement('div');
empty.className = 'settings-header';
empty.textContent = emptyText || '';
choiceList.appendChild(empty);
}
items.forEach(function (item, index) {
const row = document.createElement('div');
row.className = 'settings-item';
row.innerHTML = '<div class="setting-info"><div class="setting-label">' + escapeHtml(item.label) + '</div></div>' + controllerChevron();
row.addEventListener('click', function () { onPick(item, index); });
choiceList.appendChild(row);
});
const page = document.getElementById('controllerChoices');
if (page.style.display === 'none') navigate('controllerChoices');
else refreshSettingsSelection();
}
function openControllerStyles() {
const styles = parseJsonArray(NxEmu.getSupportedControllerStyles(controllerPlayerIndex));
openControllerChoices('Controller type', styles.map(function (id) {
return { label: controllerStyleName(id), id: id };
}), function (item) {
NxEmu.setControllerStyle(controllerPlayerIndex, item.id);
goBack();
});
}
function openAutoMap() {
const names = parseJsonArray(NxEmu.getAutoMapControllerNames());
openControllerChoices('Auto-map a controller', names.map(function (name) {
return { label: name };
}), function (item, index) {
NxEmu.autoMapController(controllerPlayerIndex, index);
alert('Ran auto-mapping against ' + item.label);
goBack();
}, 'No controllers found');
}
function openControllerFilter() {
const names = parseJsonArray(NxEmu.getControllerFilterNames());
openControllerChoices('Input mapping filter', names.map(function (name) {
return { label: name };
}), function (item, index) {
controllerFilterIndex = index;
goBack();
}, 'No controllers found');
}
function confirmResetController() {
if (!confirm("Reset this player to defaults?")) return;
NxEmu.resetControllerMappings(controllerPlayerIndex);
renderControllerPlayer();
}
function showPage(page) {
document.querySelectorAll('.page').forEach(function (p) { p.style.display = 'none'; });
document.getElementById(page).style.display = '';
if (page !== 'gamesPage') {
const remembered = settingsSelectionByPage[page];
settingsSelection = remembered == null ? 0 : remembered;
}
if (page === 'gameFolders') renderGameFolders();
if (page === 'settingsPage') renderSettingsSummary();
if (page === 'overlaySettings') renderOverlaySettings();
if (page === 'themeSettings') renderThemeSettings();
if (page === 'controllerSettings') renderControllerSettings();
if (page === 'controllerPlayer') renderControllerPlayer();
if (page === 'gamesPage' && libraryDirty) refreshGameList();
if (page !== 'gamesPage') refreshSettingsSelection();
}