From 6f2e5d9eb44e91ff88be49a5191472c67bc9da93 Mon Sep 17 00:00:00 2001 From: NullVector Date: Sun, 4 Oct 2026 21:02:26 +1030 Subject: [PATCH] Android: be able to control the menu with the controller (#345) --- src/android/app/src/main/assets/index.html | 397 +++++++++++++++++- .../nxemu/ui/emulation/EmulationActivity.kt | 108 ++++- .../java/org/nxemu/ui/main/MainActivity.kt | 123 ++++++ .../main/res/drawable/in_game_menu_item.xml | 13 + .../main/res/layout/activity_emulation.xml | 6 +- 5 files changed, 623 insertions(+), 24 deletions(-) create mode 100644 src/android/app/src/main/res/drawable/in_game_menu_item.xml diff --git a/src/android/app/src/main/assets/index.html b/src/android/app/src/main/assets/index.html index e3799f3e..c12b02e6 100644 --- a/src/android/app/src/main/assets/index.html +++ b/src/android/app/src/main/assets/index.html @@ -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(); } @@ -1190,6 +1549,11 @@ function showPage(page) {
+
+ + + Tap a game to play +
@@ -1253,6 +1620,7 @@ function showPage(page) {
Controller
+
@@ -1262,6 +1630,7 @@ function showPage(page) {
Theme
+
@@ -1290,6 +1659,7 @@ function showPage(page) {
On screen controller
+
@@ -1325,6 +1695,7 @@ function showPage(page) {
Game folders
+
diff --git a/src/android/app/src/main/java/org/nxemu/ui/emulation/EmulationActivity.kt b/src/android/app/src/main/java/org/nxemu/ui/emulation/EmulationActivity.kt index bc727e72..47890533 100644 --- a/src/android/app/src/main/java/org/nxemu/ui/emulation/EmulationActivity.kt +++ b/src/android/app/src/main/java/org/nxemu/ui/emulation/EmulationActivity.kt @@ -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(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 { + 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 } } diff --git a/src/android/app/src/main/java/org/nxemu/ui/main/MainActivity.kt b/src/android/app/src/main/java/org/nxemu/ui/main/MainActivity.kt index 8fb57c6c..085bf9ad 100644 --- a/src/android/app/src/main/java/org/nxemu/ui/main/MainActivity.kt +++ b/src/android/app/src/main/java/org/nxemu/ui/main/MainActivity.kt @@ -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 + } } diff --git a/src/android/app/src/main/res/drawable/in_game_menu_item.xml b/src/android/app/src/main/res/drawable/in_game_menu_item.xml new file mode 100644 index 00000000..b3453232 --- /dev/null +++ b/src/android/app/src/main/res/drawable/in_game_menu_item.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/layout/activity_emulation.xml b/src/android/app/src/main/res/layout/activity_emulation.xml index e92268db..a438e2c2 100644 --- a/src/android/app/src/main/res/layout/activity_emulation.xml +++ b/src/android/app/src/main/res/layout/activity_emulation.xml @@ -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"