Android: be able to control the menu with the controller (#345)
This commit is contained in:
parent
72c5d8a532
commit
6f2e5d9eb4
|
|
@ -7,9 +7,10 @@
|
|||
:root {
|
||||
--bg: #ffffff;
|
||||
--text: #111111;
|
||||
--muted: rgba(128, 128, 128, 0.85);
|
||||
--muted: #4a555c;
|
||||
--line: rgba(128, 128, 128, 0.2);
|
||||
--card: rgba(128, 128, 128, 0.1);
|
||||
--band: #f2f5f7;
|
||||
--accent: #4fc3f7;
|
||||
--accent-text: #00344a;
|
||||
}
|
||||
|
|
@ -18,6 +19,8 @@
|
|||
:root {
|
||||
--bg: #121212;
|
||||
--text: #e8e8e8;
|
||||
--muted: #b4bcc2;
|
||||
--band: #1a2226;
|
||||
--accent-text: #00344a;
|
||||
}
|
||||
}
|
||||
|
|
@ -25,11 +28,15 @@
|
|||
html.dark {
|
||||
--bg: #121212;
|
||||
--text: #e8e8e8;
|
||||
--muted: #b4bcc2;
|
||||
--band: #1a2226;
|
||||
}
|
||||
|
||||
html.light {
|
||||
--bg: #ffffff;
|
||||
--text: #111111;
|
||||
--muted: #4a555c;
|
||||
--band: #f2f5f7;
|
||||
}
|
||||
|
||||
* {
|
||||
|
|
@ -50,8 +57,8 @@
|
|||
}
|
||||
|
||||
html.theme-ready {
|
||||
transition: --bg 0.4s ease, --text 0.4s ease, --muted 0.4s ease, --line 0.4s ease, --card 0.4s ease,
|
||||
background-color 0.4s ease, color 0.4s ease;
|
||||
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,
|
||||
|
|
@ -63,6 +70,7 @@
|
|||
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,
|
||||
|
|
@ -119,7 +127,8 @@
|
|||
height: 56px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg);
|
||||
background: var(--band);
|
||||
border-bottom: 1px solid var(--line);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
|
|
@ -130,7 +139,8 @@
|
|||
height: 56px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg);
|
||||
background: var(--band);
|
||||
border-bottom: 1px solid var(--line);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
|
|
@ -199,17 +209,57 @@
|
|||
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);
|
||||
min-height: 1.2em;
|
||||
}
|
||||
|
||||
.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;
|
||||
|
|
@ -226,6 +276,23 @@
|
|||
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;
|
||||
|
|
@ -263,6 +330,18 @@
|
|||
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;
|
||||
|
|
@ -404,6 +483,57 @@
|
|||
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;
|
||||
|
|
@ -415,6 +545,13 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -460,6 +597,9 @@
|
|||
let navStack = ['gamesPage'];
|
||||
let gameListGen = 0;
|
||||
let libraryDirty = true;
|
||||
let selectedGameIndex = -1;
|
||||
let settingsSelection = -1;
|
||||
const settingsSelectionByPage = {};
|
||||
let themeReady = false;
|
||||
|
||||
function applyTheme() {
|
||||
|
|
@ -543,6 +683,7 @@ function setLibraryStatus(text) {
|
|||
|
||||
function renderEmptyGames(kind) {
|
||||
const container = document.getElementById('gameList');
|
||||
syncGameSelection(-1);
|
||||
container.className = '';
|
||||
if (kind === 'no-folders') {
|
||||
container.innerHTML =
|
||||
|
|
@ -576,6 +717,7 @@ function refreshGameList() {
|
|||
const container = document.getElementById('gameList');
|
||||
if (!container) return;
|
||||
|
||||
syncGameSelection(-1);
|
||||
const folders = getConfiguredFolders();
|
||||
if (!folders.length) {
|
||||
setLibraryStatus('');
|
||||
|
|
@ -686,9 +828,116 @@ function finishGameList(gen, cards, loaded, failed) {
|
|||
return (a.dataset.title || '').localeCompare(b.dataset.title || '');
|
||||
});
|
||||
visible.forEach(function (el) { container.appendChild(el); });
|
||||
setLibraryStatus('');
|
||||
syncGameSelection(0);
|
||||
}
|
||||
|
||||
const gameLabel = loaded === 1 ? '1 game' : loaded + ' games';
|
||||
setLibraryStatus(failed ? gameLabel + ' ? ' + failed + ' skipped' : gameLabel);
|
||||
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) {
|
||||
|
|
@ -701,6 +950,105 @@ function shortPath(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);
|
||||
|
|
@ -745,12 +1093,14 @@ function renderSettingsSummary() {
|
|||
|
||||
function renderControllersSummary() {
|
||||
const meta = document.getElementById('controllersMeta');
|
||||
if (!meta) return;
|
||||
var count = 0;
|
||||
for (var player = 0; player < 8; player++) {
|
||||
if (NxEmu.isControllerConnected(player)) count++;
|
||||
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;
|
||||
}
|
||||
meta.textContent = count === 0 ? 'Connected: none' : 'Connected: ' + count;
|
||||
applyControllerBrowser();
|
||||
}
|
||||
|
||||
function getThemeMode() {
|
||||
|
|
@ -859,6 +1209,7 @@ function renderGameFolders() {
|
|||
});
|
||||
list.appendChild(item);
|
||||
});
|
||||
refreshSettingsSelection();
|
||||
}
|
||||
|
||||
function removeGameFolder(uri) {
|
||||
|
|
@ -929,6 +1280,7 @@ function renderControllerSettings() {
|
|||
item.addEventListener('click', openControllerPlayer.bind(null, player));
|
||||
list.appendChild(item);
|
||||
}
|
||||
refreshSettingsSelection();
|
||||
}
|
||||
|
||||
function appendControllerHeader(list, title) {
|
||||
|
|
@ -1101,6 +1453,7 @@ function renderControllerPlayer() {
|
|||
appendControllerHeader(list, 'Triggers');
|
||||
appendButtons(list, [['Z', BTN.R], ['L', BTN.ZL], ['R', BTN.ZR]]);
|
||||
}
|
||||
refreshSettingsSelection();
|
||||
}
|
||||
|
||||
function openControllerChoices(title, items, onPick, emptyText) {
|
||||
|
|
@ -1123,6 +1476,7 @@ function openControllerChoices(title, items, onPick, emptyText) {
|
|||
});
|
||||
const page = document.getElementById('controllerChoices');
|
||||
if (page.style.display === 'none') navigate('controllerChoices');
|
||||
else refreshSettingsSelection();
|
||||
}
|
||||
|
||||
function openControllerStyles() {
|
||||
|
|
@ -1165,6 +1519,10 @@ function confirmResetController() {
|
|||
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();
|
||||
|
|
@ -1172,6 +1530,7 @@ function showPage(page) {
|
|||
if (page === 'controllerSettings') renderControllerSettings();
|
||||
if (page === 'controllerPlayer') renderControllerPlayer();
|
||||
if (page === 'gamesPage' && libraryDirty) refreshGameList();
|
||||
if (page !== 'gamesPage') refreshSettingsSelection();
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
|
|
@ -1190,6 +1549,11 @@ function showPage(page) {
|
|||
</div>
|
||||
<div id="libraryStatus" class="library-status"></div>
|
||||
<div id="gameList" class="game-list"></div>
|
||||
<div class="browser-hint">
|
||||
<span id="playHint" hidden>Press <span class="hint-key">A</span> <span class="hint-action">to play</span></span>
|
||||
<span id="settingsHint" hidden>Press <span class="hint-key">Y</span> <span class="hint-action">to open settings</span></span>
|
||||
<span id="touchHint">Tap a game <span class="hint-action">to play</span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="settingsPage" class="page" style="display:none">
|
||||
<div class="topbar-back">
|
||||
|
|
@ -1197,6 +1561,7 @@ function showPage(page) {
|
|||
<svg viewBox="0 0 24 24"><path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/></svg>
|
||||
</button>
|
||||
<div class="page-title">Settings</div>
|
||||
<span class="back-hint" hidden><span class="hint-pair"><span class="hint-key">A</span> <span class="hint-action">Select</span></span><span class="hint-pair"><span class="hint-key">B</span> <span class="hint-action">Back</span></span></span>
|
||||
</div>
|
||||
<div class="settings-list">
|
||||
<div class="settings-item" onclick="navigate('gameFolders')">
|
||||
|
|
@ -1235,6 +1600,7 @@ function showPage(page) {
|
|||
<svg viewBox="0 0 24 24"><path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/></svg>
|
||||
</button>
|
||||
<div class="page-title">Controllers</div>
|
||||
<span class="back-hint" hidden><span class="hint-pair"><span class="hint-key">A</span> <span class="hint-action">Select</span></span><span class="hint-pair"><span class="hint-key">B</span> <span class="hint-action">Back</span></span></span>
|
||||
</div>
|
||||
<div id="controllerList" class="settings-list"></div>
|
||||
</div>
|
||||
|
|
@ -1244,6 +1610,7 @@ function showPage(page) {
|
|||
<svg viewBox="0 0 24 24"><path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/></svg>
|
||||
</button>
|
||||
<div id="controllerPlayerTitle" class="page-title">Controller</div>
|
||||
<span class="back-hint" hidden><span class="hint-pair"><span class="hint-key">A</span> <span class="hint-action">Select</span></span><span class="hint-pair"><span class="hint-key">B</span> <span class="hint-action">Back</span></span></span>
|
||||
</div>
|
||||
<div id="controllerPlayerList" class="settings-list"></div>
|
||||
</div>
|
||||
|
|
@ -1253,6 +1620,7 @@ function showPage(page) {
|
|||
<svg viewBox="0 0 24 24"><path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/></svg>
|
||||
</button>
|
||||
<div id="controllerChoicesTitle" class="page-title">Controller</div>
|
||||
<span class="back-hint" hidden><span class="hint-pair"><span class="hint-key">A</span> <span class="hint-action">Select</span></span><span class="hint-pair"><span class="hint-key">B</span> <span class="hint-action">Back</span></span></span>
|
||||
</div>
|
||||
<div id="controllerChoiceList" class="settings-list"></div>
|
||||
</div>
|
||||
|
|
@ -1262,6 +1630,7 @@ function showPage(page) {
|
|||
<svg viewBox="0 0 24 24"><path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/></svg>
|
||||
</button>
|
||||
<div class="page-title">Theme</div>
|
||||
<span class="back-hint" hidden><span class="hint-pair"><span class="hint-key">A</span> <span class="hint-action">Select</span></span><span class="hint-pair"><span class="hint-key">B</span> <span class="hint-action">Back</span></span></span>
|
||||
</div>
|
||||
<div class="settings-list">
|
||||
<div class="settings-item" onclick="setThemeMode(0)">
|
||||
|
|
@ -1290,6 +1659,7 @@ function showPage(page) {
|
|||
<svg viewBox="0 0 24 24"><path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/></svg>
|
||||
</button>
|
||||
<div class="page-title">On screen controller</div>
|
||||
<span class="back-hint" hidden><span class="hint-pair"><span class="hint-key">A</span> <span class="hint-action">Select</span></span><span class="hint-pair"><span class="hint-key">B</span> <span class="hint-action">Back</span></span></span>
|
||||
</div>
|
||||
<div class="settings-list">
|
||||
<div class="settings-item" onclick="toggleOverlayBool('nxui:ShowInputOverlay', 'overlayShow')">
|
||||
|
|
@ -1325,6 +1695,7 @@ function showPage(page) {
|
|||
<svg viewBox="0 0 24 24"><path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/></svg>
|
||||
</button>
|
||||
<div class="page-title">Game folders</div>
|
||||
<span class="back-hint" hidden><span class="hint-pair"><span class="hint-key">A</span> <span class="hint-action">Select</span></span><span class="hint-pair"><span class="hint-key">B</span> <span class="hint-action">Back</span></span></span>
|
||||
</div>
|
||||
<div id="folderList" class="settings-list"></div>
|
||||
<div id="noFolders" class="empty-state">
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import android.os.SystemClock
|
|||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import android.view.Gravity
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import android.view.SurfaceHolder
|
||||
|
|
@ -64,6 +65,16 @@ class EmulationActivity : ComponentActivity(), SurfaceHolder.Callback {
|
|||
private lateinit var overlayAppVersion: String
|
||||
private lateinit var overlayPhoneModel: String
|
||||
private lateinit var overlaySoc: String
|
||||
private var drawerStickY = 0
|
||||
private val drawerStickRepeat = object : Runnable {
|
||||
override fun run() {
|
||||
if (drawerStickY == 0 || !::drawerLayout.isInitialized || !drawerLayout.isDrawerOpen(Gravity.START)) {
|
||||
return
|
||||
}
|
||||
moveDrawerFocus(drawerStickY)
|
||||
perfStatsHandler.postDelayed(this, DRAWER_REPEAT_MS)
|
||||
}
|
||||
}
|
||||
|
||||
private val settingChangedListener: (String) -> Unit = { setting ->
|
||||
if (setting == NXCoreSetting.DisplayedFrames) {
|
||||
|
|
@ -133,13 +144,26 @@ class EmulationActivity : ComponentActivity(), SurfaceHolder.Callback {
|
|||
override fun onResume() {
|
||||
super.onResume()
|
||||
InputHandler.updateControllerData()
|
||||
if (::surfaceInputOverlay.isInitialized && !surfaceInputOverlay.isInEditMode()) {
|
||||
if (::surfaceInputOverlay.isInitialized) {
|
||||
updateInputOverlayLayout()
|
||||
surfaceInputOverlay.refreshControls()
|
||||
}
|
||||
}
|
||||
|
||||
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
|
||||
if (event.keyCode == KeyEvent.KEYCODE_BACK) {
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
val source = event.source
|
||||
val fromGamepad = source and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD ||
|
||||
source and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK
|
||||
if (!fromGamepad) {
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
if (::drawerLayout.isInitialized && drawerLayout.isDrawerOpen(Gravity.START)) {
|
||||
handleDrawerPad(event)
|
||||
return true
|
||||
}
|
||||
if (InputHandler.dispatchKeyEvent(event)) {
|
||||
return true
|
||||
}
|
||||
|
|
@ -147,6 +171,12 @@ class EmulationActivity : ComponentActivity(), SurfaceHolder.Callback {
|
|||
}
|
||||
|
||||
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
|
||||
val fromStick = event.source and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK ||
|
||||
event.source and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD
|
||||
if (fromStick && ::drawerLayout.isInitialized && drawerLayout.isDrawerOpen(Gravity.START)) {
|
||||
handleDrawerStick(event)
|
||||
return true
|
||||
}
|
||||
if (InputHandler.dispatchGenericMotionEvent(event)) {
|
||||
return true
|
||||
}
|
||||
|
|
@ -165,17 +195,12 @@ class EmulationActivity : ComponentActivity(), SurfaceHolder.Callback {
|
|||
|
||||
override fun onDrawerOpened(drawerView: View) {
|
||||
drawerLayout.setDrawerLockMode(DrawerLayout.LOCK_MODE_UNLOCKED)
|
||||
findViewById<View>(R.id.in_game_menu).requestFocus()
|
||||
drawerMenuItems().first().requestFocus()
|
||||
}
|
||||
|
||||
override fun onDrawerClosed(drawerView: View) {
|
||||
drawerLayout.setDrawerLockMode(
|
||||
if (surfaceInputOverlay.isInEditMode()) {
|
||||
DrawerLayout.LOCK_MODE_LOCKED_CLOSED
|
||||
} else {
|
||||
savedDrawerLockMode()
|
||||
},
|
||||
)
|
||||
releaseDrawerStick()
|
||||
drawerLayout.setDrawerLockMode(savedDrawerLockMode())
|
||||
if (loadingIndicator.visibility != View.VISIBLE) {
|
||||
surfaceInputOverlay.visibility = View.VISIBLE
|
||||
}
|
||||
|
|
@ -225,6 +250,69 @@ class EmulationActivity : ComponentActivity(), SurfaceHolder.Callback {
|
|||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun drawerMenuItems(): List<View> {
|
||||
return listOf(
|
||||
lockDrawerItem,
|
||||
findViewById(R.id.menu_exit),
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleDrawerPad(event: KeyEvent) {
|
||||
val down = event.action == KeyEvent.ACTION_DOWN
|
||||
when (event.keyCode) {
|
||||
KeyEvent.KEYCODE_BUTTON_B -> {
|
||||
if (down && event.repeatCount == 0) {
|
||||
drawerLayout.closeDrawer(Gravity.START)
|
||||
}
|
||||
}
|
||||
KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER -> {
|
||||
if (down && event.repeatCount == 0) {
|
||||
currentDrawerItem().performClick()
|
||||
}
|
||||
}
|
||||
KeyEvent.KEYCODE_DPAD_UP -> if (down) moveDrawerFocus(-1)
|
||||
KeyEvent.KEYCODE_DPAD_DOWN -> if (down) moveDrawerFocus(1)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDrawerStick(event: MotionEvent) {
|
||||
val y = when {
|
||||
event.getAxisValue(MotionEvent.AXIS_Y) > DRAWER_STICK_DEADZONE -> 1
|
||||
event.getAxisValue(MotionEvent.AXIS_Y) < -DRAWER_STICK_DEADZONE -> -1
|
||||
else -> 0
|
||||
}
|
||||
if (y == drawerStickY) {
|
||||
return
|
||||
}
|
||||
drawerStickY = y
|
||||
perfStatsHandler.removeCallbacks(drawerStickRepeat)
|
||||
if (y == 0) {
|
||||
return
|
||||
}
|
||||
moveDrawerFocus(y)
|
||||
perfStatsHandler.postDelayed(drawerStickRepeat, DRAWER_REPEAT_MS)
|
||||
}
|
||||
|
||||
private fun releaseDrawerStick() {
|
||||
drawerStickY = 0
|
||||
perfStatsHandler.removeCallbacks(drawerStickRepeat)
|
||||
}
|
||||
|
||||
private fun moveDrawerFocus(delta: Int) {
|
||||
val items = drawerMenuItems()
|
||||
if (items.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val current = items.indexOfFirst { it.hasFocus() }.let { if (it < 0) 0 else it }
|
||||
items[(current + delta).coerceIn(0, items.lastIndex)].requestFocus()
|
||||
}
|
||||
|
||||
private fun currentDrawerItem(): View {
|
||||
val items = drawerMenuItems()
|
||||
return items.firstOrNull { it.hasFocus() } ?: items.first().also { it.requestFocus() }
|
||||
}
|
||||
|
||||
private fun hideSystemBars() {
|
||||
WindowCompat.getInsetsController(window, window.decorView).apply {
|
||||
hide(WindowInsetsCompat.Type.systemBars())
|
||||
|
|
@ -470,5 +558,7 @@ class EmulationActivity : ComponentActivity(), SurfaceHolder.Callback {
|
|||
companion object {
|
||||
const val EXTRA_GAME_PATH = "org.nxemu.EXTRA_GAME_PATH"
|
||||
private const val TAG = "NxEmu-Emulation"
|
||||
private const val DRAWER_REPEAT_MS = 220L
|
||||
private const val DRAWER_STICK_DEADZONE = 0.5f
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
package org.nxemu.ui.main
|
||||
|
||||
import android.content.Intent
|
||||
import android.hardware.input.InputManager
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.InputDevice
|
||||
import android.view.KeyEvent
|
||||
import android.view.MotionEvent
|
||||
import android.webkit.ConsoleMessage
|
||||
|
|
@ -28,6 +30,22 @@ class MainActivity : ComponentActivity() {
|
|||
private lateinit var webView: WebView
|
||||
private val inputMapping = InputMappingSession(this) { refreshControllerPages() }
|
||||
private var emulationLaunchPending = false
|
||||
private var stickX = 0
|
||||
private var stickY = 0
|
||||
private val stickRepeat = object : Runnable {
|
||||
override fun run() {
|
||||
if (stickX == 0 && stickY == 0) return
|
||||
nudgeBrowserStick()
|
||||
if (::webView.isInitialized && !isDestroyed) {
|
||||
webView.postDelayed(this, STICK_REPEAT_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
private val inputDeviceListener = object : InputManager.InputDeviceListener {
|
||||
override fun onInputDeviceAdded(deviceId: Int) = refreshConnectedControllers()
|
||||
override fun onInputDeviceRemoved(deviceId: Int) = refreshConnectedControllers()
|
||||
override fun onInputDeviceChanged(deviceId: Int) = refreshConnectedControllers()
|
||||
}
|
||||
private val settingChangedForwarder: (String) -> Unit = { setting ->
|
||||
runOnUiThread {
|
||||
webView.evaluateJavascript(
|
||||
|
|
@ -137,22 +155,108 @@ class MainActivity : ComponentActivity() {
|
|||
if (inputMapping.onKey(event)) {
|
||||
return true
|
||||
}
|
||||
if (handleBrowserPad(event)) {
|
||||
return true
|
||||
}
|
||||
if (InputHandler.dispatchKeyEvent(event)) {
|
||||
return true
|
||||
}
|
||||
return super.dispatchKeyEvent(event)
|
||||
}
|
||||
|
||||
private fun handleBrowserPad(event: KeyEvent): Boolean {
|
||||
val script = when (event.keyCode) {
|
||||
KeyEvent.KEYCODE_BUTTON_Y ->
|
||||
browserPadScript(event, repeat = false, "typeof openSettingsFromPad==='function'&&openSettingsFromPad()")
|
||||
KeyEvent.KEYCODE_BUTTON_B ->
|
||||
browserPadScript(event, repeat = false, "typeof goBackFromPad==='function'&&goBackFromPad()")
|
||||
KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_DPAD_CENTER ->
|
||||
browserPadScript(event, repeat = false, "typeof launchSelectedGame==='function'&&launchSelectedGame()")
|
||||
KeyEvent.KEYCODE_DPAD_LEFT ->
|
||||
browserPadScript(event, repeat = true, "typeof moveGameSelection==='function'&&moveGameSelection('left')")
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT ->
|
||||
browserPadScript(event, repeat = true, "typeof moveGameSelection==='function'&&moveGameSelection('right')")
|
||||
KeyEvent.KEYCODE_DPAD_UP ->
|
||||
browserPadScript(event, repeat = true, "typeof moveGameSelection==='function'&&moveGameSelection('up')")
|
||||
KeyEvent.KEYCODE_DPAD_DOWN ->
|
||||
browserPadScript(event, repeat = true, "typeof moveGameSelection==='function'&&moveGameSelection('down')")
|
||||
else -> return false
|
||||
}
|
||||
if (script.isNotEmpty() && ::webView.isInitialized && !isDestroyed) {
|
||||
webView.evaluateJavascript(script, null)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun browserPadScript(event: KeyEvent, repeat: Boolean, script: String): String {
|
||||
if (event.action != KeyEvent.ACTION_DOWN) return ""
|
||||
if (!repeat && event.repeatCount != 0) return ""
|
||||
return script
|
||||
}
|
||||
|
||||
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
|
||||
if (inputMapping.onMotion(event)) {
|
||||
return true
|
||||
}
|
||||
if (handleBrowserStick(event)) {
|
||||
return true
|
||||
}
|
||||
if (InputHandler.dispatchGenericMotionEvent(event)) {
|
||||
return true
|
||||
}
|
||||
return super.dispatchGenericMotionEvent(event)
|
||||
}
|
||||
|
||||
private fun handleBrowserStick(event: MotionEvent): Boolean {
|
||||
val source = event.source
|
||||
val fromStick = source and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK ||
|
||||
source and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD
|
||||
if (!fromStick) {
|
||||
return false
|
||||
}
|
||||
val x = stickDirection(event.getAxisValue(MotionEvent.AXIS_X))
|
||||
val y = stickDirection(event.getAxisValue(MotionEvent.AXIS_Y))
|
||||
if (x == stickX && y == stickY) {
|
||||
return x != 0 || y != 0
|
||||
}
|
||||
stickX = x
|
||||
stickY = y
|
||||
if (::webView.isInitialized) {
|
||||
webView.removeCallbacks(stickRepeat)
|
||||
}
|
||||
if (x == 0 && y == 0) {
|
||||
return false
|
||||
}
|
||||
nudgeBrowserStick()
|
||||
if (::webView.isInitialized && !isDestroyed) {
|
||||
webView.postDelayed(stickRepeat, STICK_REPEAT_MS)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun stickDirection(value: Float): Int {
|
||||
return when {
|
||||
value > STICK_DEADZONE -> 1
|
||||
value < -STICK_DEADZONE -> -1
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
|
||||
private fun nudgeBrowserStick() {
|
||||
if (!::webView.isInitialized || isDestroyed) {
|
||||
return
|
||||
}
|
||||
val moves = buildString {
|
||||
if (stickX < 0) append("moveGameSelection('left');")
|
||||
if (stickX > 0) append("moveGameSelection('right');")
|
||||
if (stickY < 0) append("moveGameSelection('up');")
|
||||
if (stickY > 0) append("moveGameSelection('down');")
|
||||
}
|
||||
if (moves.isEmpty()) {
|
||||
return
|
||||
}
|
||||
webView.evaluateJavascript("if(typeof moveGameSelection==='function'){$moves}", null)
|
||||
}
|
||||
fun beginControllerMap(playerIndex: Int, mapId: String, title: String, filterIndex: Int) {
|
||||
runOnUiThread {
|
||||
inputMapping.begin(playerIndex, mapId, title, filterIndex)
|
||||
|
|
@ -173,6 +277,20 @@ class MainActivity : ComponentActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
private fun refreshConnectedControllers() {
|
||||
if (!::webView.isInitialized || isDestroyed) {
|
||||
return
|
||||
}
|
||||
webView.post {
|
||||
if (!isDestroyed) {
|
||||
webView.evaluateJavascript(
|
||||
"typeof renderControllersSummary==='function'&&renderControllersSummary()",
|
||||
null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun editOverlayLayout() {
|
||||
startActivity(Intent(this, OverlayLayoutActivity::class.java))
|
||||
}
|
||||
|
|
@ -204,4 +322,9 @@ class MainActivity : ComponentActivity() {
|
|||
null,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val STICK_REPEAT_MS = 220L
|
||||
private const val STICK_DEADZONE = 0.5f
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:state_focused="true">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#337EF6FF" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@android:color/transparent" />
|
||||
</shape>
|
||||
</item>
|
||||
</selector>
|
||||
|
|
@ -160,9 +160,10 @@
|
|||
android:id="@+id/menu_lock_drawer"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="48dp"
|
||||
android:background="?android:attr/selectableItemBackground"
|
||||
android:background="@drawable/in_game_menu_item"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:focusableInTouchMode="true"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingHorizontal="20dp"
|
||||
android:text="@string/lock_drawer"
|
||||
|
|
@ -173,9 +174,10 @@
|
|||
android:id="@+id/menu_exit"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="48dp"
|
||||
android:background="?android:attr/selectableItemBackground"
|
||||
android:background="@drawable/in_game_menu_item"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:focusableInTouchMode="true"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingHorizontal="20dp"
|
||||
android:text="@string/emulation_exit"
|
||||
|
|
|
|||
Loading…
Reference in New Issue