From 16e110cb08ea4949de530e5e6fd25bc75e13c516 Mon Sep 17 00:00:00 2001 From: max Date: Mon, 13 Jul 2026 11:01:54 -0400 Subject: [PATCH 01/38] Add retro console emulation backend (libretro) alongside the Wine path Import a console ROM from the Library exactly like a PC game and play it on an embedded libretro backend (LibretroDroid GLRetroView), running in parallel to the Wine/Proton emulator instead of replacing it. - feature/retro: RetroSystem registry (NES, SNES, GB/GBC, GBA, Genesis/MD/SMS/GG, N64, PS1), RetroCoreManager, RetroShortcuts, RetroActivity (GLRetroView host), RetroInputView (on-screen gamepad), RetroGameSettingsDialog - Bundle arm64-v8a cores (fceumm, snes9x, gambatte, mgba, genesis_plus_gx, pcsx_rearmed, mupen64plus_next_gles3) in jniLibs - Retro games are CUSTOM shortcuts tagged with retro_system/rom_path so they reuse the whole Library display pipeline - Import: the Add-Custom-Game picker now accepts ROM extensions - Backend-gated launch: launchCustomGame, ShortcutsFragment.runFromShortcut, and a defensive redirect in XServerDisplayActivity all route retro to RetroActivity - Detail dialog hides Wine-only actions for retro games and opens retro settings - Physical controllers and on-screen controls handled inside RetroActivity, isolated from the Wine input stack Emulator sources and credit: - The retro backend embeds LibretroDroid by Filippo Scognamiglio (GPL-3.0) as the libretro frontend host, consumed as the unmodified upstream artifact com.github.Swordfish90:libretrodroid:0.14.0 from https://github.com/Swordfish90/LibretroDroid - The bundled emulator cores are unmodified official arm64-v8a libretro builds obtained from the libretro buildbot (https://buildbot.libretro.com/nightly/android/latest/arm64-v8a/); each core's source, history, and license remain intact at its upstream project: - FCEUmm (NES) https://github.com/libretro/libretro-fceumm - Snes9x (SNES) https://github.com/libretro/snes9x - Gambatte (GB/GBC) https://github.com/libretro/gambatte-libretro - mGBA (GBA) https://github.com/mgba-emu/mgba - Genesis Plus GX (Genesis/MS/GG) https://github.com/ekeeke/Genesis-Plus-GX - Mupen64Plus-Next (N64) https://github.com/libretro/mupen64plus-libretro-nx - PCSX-ReARMed (PS1) https://github.com/libretro/pcsx_rearmed - No emulator source code was copied into this repository; cores ship as prebuilt binaries under their respective upstream licenses, and the libretro/RetroArch project and core authors are credited in README.md --- .gitattributes | 2 + README.md | 20 + app/build.gradle | 2 + app/src/main/AndroidManifest.xml | 8 + .../main/app/shell/LibraryGameLaunchScreen.kt | 47 +-- app/src/main/app/shell/UnifiedActivity.kt | 227 +++++++---- app/src/main/feature/retro/RetroActivity.kt | 353 ++++++++++++++++++ .../main/feature/retro/RetroCoreManager.kt | 41 ++ .../feature/retro/RetroGameSettingsDialog.kt | 114 ++++++ app/src/main/feature/retro/RetroInputView.kt | 266 +++++++++++++ app/src/main/feature/retro/RetroShortcuts.kt | 80 ++++ app/src/main/feature/retro/RetroSystem.kt | 131 +++++++ .../feature/shortcuts/ShortcutsFragment.java | 6 + .../arm64-v8a/libfceumm_libretro_android.so | 3 + .../arm64-v8a/libgambatte_libretro_android.so | 3 + .../libgenesis_plus_gx_libretro_android.so | 3 + .../arm64-v8a/libmgba_libretro_android.so | 3 + ...mupen64plus_next_gles3_libretro_android.so | 3 + .../libpcsx_rearmed_libretro_android.so | 3 + .../arm64-v8a/libsnes9x_libretro_android.so | 3 + .../display/XServerDisplayActivity.java | 8 + 21 files changed, 1241 insertions(+), 85 deletions(-) create mode 100644 app/src/main/feature/retro/RetroActivity.kt create mode 100644 app/src/main/feature/retro/RetroCoreManager.kt create mode 100644 app/src/main/feature/retro/RetroGameSettingsDialog.kt create mode 100644 app/src/main/feature/retro/RetroInputView.kt create mode 100644 app/src/main/feature/retro/RetroShortcuts.kt create mode 100644 app/src/main/feature/retro/RetroSystem.kt create mode 100755 app/src/main/jniLibs/arm64-v8a/libfceumm_libretro_android.so create mode 100755 app/src/main/jniLibs/arm64-v8a/libgambatte_libretro_android.so create mode 100755 app/src/main/jniLibs/arm64-v8a/libgenesis_plus_gx_libretro_android.so create mode 100755 app/src/main/jniLibs/arm64-v8a/libmgba_libretro_android.so create mode 100755 app/src/main/jniLibs/arm64-v8a/libmupen64plus_next_gles3_libretro_android.so create mode 100755 app/src/main/jniLibs/arm64-v8a/libpcsx_rearmed_libretro_android.so create mode 100755 app/src/main/jniLibs/arm64-v8a/libsnes9x_libretro_android.so diff --git a/.gitattributes b/.gitattributes index 6fd1589a1..0b6224cf0 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,3 +3,5 @@ app/src/main/assets/imagefs.tzst filter=lfs diff=lfs merge=lfs -text app/src/main/assets/imagefs.part*.tzst filter=lfs diff=lfs merge=lfs -text reference/tooling/jadx.zip filter=lfs diff=lfs merge=lfs -text reference/tooling/jadx/lib/jadx-1.5.3-all.jar filter=lfs diff=lfs merge=lfs -text + +app/src/main/jniLibs/arm64-v8a/*_libretro_android.so filter=lfs diff=lfs merge=lfs -text diff --git a/README.md b/README.md index 7f2622749..8544385fb 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,24 @@ Designed for enthusiasts and power users, WinNative delivers the full Winlator e --- +### Retro Console Support + +WinNative can also run classic console games alongside your PC library. Retro games live in the same Library and launch just like PC games, but run on an embedded libretro backend instead of Wine. + +Supported systems (bundled cores): + +| System | Core | ROM extensions | +| --- | --- | --- | +| NES | FCEUmm | `.nes` `.unf` `.unif` | +| SNES | Snes9x | `.smc` `.sfc` `.swc` `.fig` | +| Game Boy / Color | Gambatte | `.gb` `.gbc` | +| Game Boy Advance | mGBA | `.gba` | +| Genesis / Mega Drive, Master System, Game Gear | Genesis Plus GX | `.gen` `.md` `.smd` `.sms` `.gg` | +| Nintendo 64 | Mupen64Plus-Next | `.n64` `.z64` `.v64` | +| PlayStation | PCSX-ReARMed | `.cue` `.chd` `.pbp` `.m3u` `.iso` | + +**How to use:** In the Library, tap **Add Custom Game** and select a ROM instead of an `.exe`. WinNative detects the console and adds the game to your Library. Tap **Play** to launch it with on-screen touch controls and physical gamepad support; the in-game menu (Back button or on-screen **MENU**) offers save/load state, reset, and fast-forward. PlayStation BIOS files (e.g. `scph5501.bin`) can be placed in the app's `files/retro/system` folder for better compatibility. + ### Contributing We welcome community contributions! Feel free to open a pull request for bug fixes, driver updates, UI improvements, or anything else you'd like to add. @@ -56,3 +74,5 @@ Please match the existing code style and ensure any AI-assisted code is thorough - **Pluvia** features by the [Pluvia](https://github.com/oxters168/Pluvia) / [GameNative](https://github.com/utkarshdalal/GameNative) community - **Mesa/Turnip** contributions by the [Mesa3D](https://www.mesa3d.org/) team - **Goldberg Steam Emulator** by [Mr. Goldberg](https://gitlab.com/Mr_Goldberg/goldberg_emulator), maintained by [Detanup01](https://github.com/Detanup01/gbe_fork) +- **LibretroDroid** by [Filippo Scognamiglio](https://github.com/Swordfish90/LibretroDroid) (GPL-3.0) — the embedded libretro host for retro console support +- **libretro / RetroArch** and the individual core authors (FCEUmm, Snes9x, Gambatte, mGBA, Genesis Plus GX, Mupen64Plus-Next, PCSX-ReARMed) diff --git a/app/build.gradle b/app/build.gradle index 9a83a7dea..76810113f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -280,6 +280,8 @@ dependencies { implementation libs.playServicesGamesV2 implementation libs.workRuntimeKtx + + implementation 'com.github.Swordfish90:libretrodroid:0.14.0' } spotless { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index dcb6bc9e1..86467ead4 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -127,6 +127,14 @@ android:screenOrientation="sensorLandscape" android:exported="false" /> + + diff --git a/app/src/main/app/shell/LibraryGameLaunchScreen.kt b/app/src/main/app/shell/LibraryGameLaunchScreen.kt index 66f62fb8f..801b8df6f 100644 --- a/app/src/main/app/shell/LibraryGameLaunchScreen.kt +++ b/app/src/main/app/shell/LibraryGameLaunchScreen.kt @@ -134,6 +134,7 @@ internal fun LibraryGameLaunchScreen( lastPlayedMillis: Long, installSizeText: String?, isCustom: Boolean, + isRetro: Boolean = false, hasPinnedShortcut: Boolean, steamMenuEnabled: Boolean = false, areSteamActionsEnabled: Boolean = true, @@ -165,7 +166,7 @@ internal fun LibraryGameLaunchScreen( val actionIconSize = 46.dp val actionIconSpacing = 8.dp // Action icons: Settings, Boot, CloudSync, Shortcut, Delete. - val actionIconCount = 5 + val actionIconCount = if (isRetro) 2 else 5 val actionWidth = actionIconSize * actionIconCount + actionIconSpacing * (actionIconCount - 1) val playHeight = 56.dp val contentGap = 18.dp @@ -391,27 +392,29 @@ internal fun LibraryGameLaunchScreen( size = actionIconSize, onClick = onSettings, ) - LaunchIconActionButton( - icon = Icons.Outlined.DesktopWindows, - contentDescription = stringResource(R.string.hero_boot_to_desktop_title), - size = actionIconSize, - onClick = onBootToDesktop, - ) - LaunchIconActionButton( - icon = Icons.Outlined.CloudSync, - contentDescription = stringResource(R.string.cloud_saves_title), - size = actionIconSize, - onClick = onCloudSaves, - ) - LaunchIconActionButton( - icon = Icons.Outlined.Home, - contentDescription = - stringResource( - if (hasPinnedShortcut) R.string.common_ui_remove else R.string.common_ui_shortcut, - ), - size = actionIconSize, - onClick = onShortcut, - ) + if (!isRetro) { + LaunchIconActionButton( + icon = Icons.Outlined.DesktopWindows, + contentDescription = stringResource(R.string.hero_boot_to_desktop_title), + size = actionIconSize, + onClick = onBootToDesktop, + ) + LaunchIconActionButton( + icon = Icons.Outlined.CloudSync, + contentDescription = stringResource(R.string.cloud_saves_title), + size = actionIconSize, + onClick = onCloudSaves, + ) + LaunchIconActionButton( + icon = Icons.Outlined.Home, + contentDescription = + stringResource( + if (hasPinnedShortcut) R.string.common_ui_remove else R.string.common_ui_shortcut, + ), + size = actionIconSize, + onClick = onShortcut, + ) + } Box { LaunchIconActionButton( icon = Icons.Outlined.Delete, diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index 42ca554ba..ca616f2dc 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -5685,6 +5685,27 @@ class UnifiedActivity : val isGog = gogGame != null val epicId = if (isEpic) app.id - 2000000000 else 0 + var retroSystemId by remember(app.id) { mutableStateOf(null) } + var retroSettingsShortcut by remember(app.id) { mutableStateOf(null) } + var showRetroSettings by remember(app.id) { mutableStateOf(false) } + LaunchedEffect(app.id, isCustom) { + if (isCustom) { + withContext(Dispatchers.IO) { + val sc = findLibraryShortcutForGame(ContainerManager(context), app, true, false, 0) + val sys = + sc + ?.getExtra(com.winlator.cmod.feature.retro.RetroShortcuts.KEY_SYSTEM) + ?.takeIf { it.isNotEmpty() } + retroSystemId = sys + retroSettingsShortcut = if (sys != null) sc else null + } + } else { + retroSystemId = null + retroSettingsShortcut = null + } + } + val isRetro = retroSystemId != null + val libraryDownloadRecords by com.winlator.cmod.app.service.download.DownloadCoordinator.records.collectAsState( initial = com.winlator.cmod.app.service.download.DownloadCoordinator.snapshotRecords(), ) @@ -6328,6 +6349,7 @@ class UnifiedActivity : lastPlayedMillis = lastPlayed, installSizeText = installSizeText, isCustom = isCustom, + isRetro = isRetro, hasPinnedShortcut = hasPinnedShortcut, playEnabled = playEnabled, playDisabledLabel = playDisabledLabel, @@ -6347,7 +6369,12 @@ class UnifiedActivity : }, onSettings = { val shortcut = resolveOrCreateShortcut() - if (shortcut != null) { + if (shortcut != null && + com.winlator.cmod.feature.retro.RetroShortcuts.isRetroShortcut(shortcut) + ) { + retroSettingsShortcut = shortcut + showRetroSettings = true + } else if (shortcut != null) { // Layer the settings dialog on top; keep the detail dialog open underneath. ShortcutSettingsComposeDialog(this@UnifiedActivity, shortcut).show() } @@ -6463,6 +6490,15 @@ class UnifiedActivity : onWorkshop = { if (!isEpic && !isGog) showWorkshopDialog = true }, ) + if (showRetroSettings) { + retroSettingsShortcut?.let { retroShortcut -> + com.winlator.cmod.feature.retro.RetroGameSettingsDialog( + shortcut = retroShortcut, + onDismiss = { showRetroSettings = false }, + ) + } + } + when (heroPopup) { HeroLaunchPopup.BootToDesktop -> HeroBootDialog( @@ -11542,6 +11578,12 @@ class UnifiedActivity : return@launch } + if (com.winlator.cmod.feature.retro.RetroShortcuts.isRetroShortcut(shortcut)) { + val retroIntent = com.winlator.cmod.feature.retro.RetroShortcuts.launchIntent(context, shortcut) + withContext(Dispatchers.Main) { launchGame(context, retroIntent) } + return@launch + } + // Backfill custom_name if missing (legacy shortcuts) if (shortcut.getExtra("custom_name").isEmpty()) { shortcut.putExtra("custom_name", gameName) @@ -12156,31 +12198,52 @@ class UnifiedActivity : var selectedExePath by remember { mutableStateOf(null) } var gameName by remember { mutableStateOf("") } var gameFolder by remember { mutableStateOf(null) } + var retroSystem by remember { mutableStateOf(null) } var isAdding by remember { mutableStateOf(false) } val registry = remember { PaneNavRegistry() } - val addEnabled = selectedExePath != null && gameName.isNotBlank() && gameFolder != null && !isAdding + val addEnabled = + selectedExePath != null && gameName.isNotBlank() && !isAdding && + (retroSystem != null || gameFolder != null) val doAdd: () -> Unit = { isAdding = true + val chosenRetro = retroSystem scope.launch(Dispatchers.IO) { - addCustomGame(context, gameName.trim(), selectedExePath!!, gameFolder!!) + val added = + if (chosenRetro != null) { + com.winlator.cmod.feature.retro.RetroShortcuts + .create(context, gameName.trim(), selectedExePath!!, chosenRetro) + } else { + addCustomGame(context, gameName.trim(), selectedExePath!!, gameFolder!!) + true + } withContext(Dispatchers.Main) { isAdding = false - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - "$gameName added!", - android.widget.Toast.LENGTH_SHORT, - ) - onDismiss() + if (added) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + "$gameName added!", + android.widget.Toast.LENGTH_SHORT, + ) + onDismiss() + } else { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + "Could not add game", + android.widget.Toast.LENGTH_SHORT, + ) + } } } } fun selectExecutable(path: String) { + val extension = path.substringAfterLast('.', "").lowercase(java.util.Locale.US) + val detectedRetro = com.winlator.cmod.feature.retro.RetroSystems.fromExtension(extension) val launchable = path.endsWith(".exe", ignoreCase = true) || path.endsWith(".bat", ignoreCase = true) || path.endsWith(".cmd", ignoreCase = true) - if (!launchable || !java.io.File(path).isFile) { + if ((!launchable && detectedRetro == null) || !java.io.File(path).isFile) { com.winlator.cmod.shared.ui.toast.WinToast.show( context, R.string.common_ui_select_valid_exe_file, @@ -12190,8 +12253,13 @@ class UnifiedActivity : } selectedExePath = path - gameFolder = LibraryShortcutUtils.detectCustomGameFolder(path) - // Auto-generate a game name from the EXE name (without extension) + retroSystem = detectedRetro + gameFolder = + if (detectedRetro != null) { + java.io.File(path).parent + } else { + LibraryShortcutUtils.detectCustomGameFolder(path) + } if (gameName.isBlank()) { gameName = java.io @@ -12260,7 +12328,9 @@ class UnifiedActivity : android.os.Environment.DIRECTORY_DOWNLOADS, ).absolutePath, title = getString(R.string.common_ui_select_exe), - allowedExtensions = setOf("exe", "bat", "cmd"), + allowedExtensions = + setOf("exe", "bat", "cmd") + + com.winlator.cmod.feature.retro.RetroSystems.allExtensions, dimAmount = 0.5f, preserveBackdropBlur = true, extraRoots = driveRoots(includeInternal = true), @@ -12273,7 +12343,7 @@ class UnifiedActivity : Icon(Icons.Outlined.FolderOpen, contentDescription = null, tint = Accent, modifier = Modifier.size(16.dp)) Spacer(Modifier.width(8.dp)) Text( - selectedExePath ?: "Select Executable (.exe)", + selectedExePath ?: "Select Executable or Console ROM", color = if (selectedExePath == null) TextSecondary else TextPrimary, maxLines = if (selectedExePath == null) 1 else Int.MAX_VALUE, overflow = if (selectedExePath == null) TextOverflow.Ellipsis else TextOverflow.Visible, @@ -12309,63 +12379,94 @@ class UnifiedActivity : Spacer(Modifier.height(8.dp)) - // Game folder — single compact row - Row( - modifier = - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(10.dp)) - .background(Color.White.copy(alpha = 0.05f)) - .padding(horizontal = 10.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - Icons.Outlined.Folder, - contentDescription = null, - tint = StatusOnline.copy(alpha = 0.7f), - modifier = Modifier.size(14.dp), - ) - Spacer(Modifier.width(6.dp)) - Column(Modifier.weight(1f)) { - Text( - stringResource(R.string.library_games_game_folder_mapped_drive), - color = TextSecondary, - fontSize = 9.sp, - ) - Text( - gameFolder ?: stringResource(R.string.common_ui_auto_detected), - color = if (gameFolder != null) TextPrimary else TextSecondary, - fontSize = 10.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + val activeRetroSystem = retroSystem + if (activeRetroSystem != null) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(Color.White.copy(alpha = 0.05f)) + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Outlined.SportsEsports, + contentDescription = null, + tint = StatusOnline.copy(alpha = 0.7f), + modifier = Modifier.size(14.dp), ) - } - val openFolderPicker = { - if (ensureAllFilesAccessForImports(context)) { - DirectoryPickerDialog.show( - activity = this@UnifiedActivity, - initialPath = gameFolder, - title = getString(R.string.common_ui_select_folder), - dimAmount = 0.5f, - preserveBackdropBlur = true, - extraRoots = driveRoots(includeInternal = true), - ) { path -> gameFolder = path } + Spacer(Modifier.width(6.dp)) + Column(Modifier.weight(1f)) { + Text("Console", color = TextSecondary, fontSize = 9.sp) + Text( + activeRetroSystem.displayName, + color = TextPrimary, + fontSize = 10.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } } - IconButton( - onClick = openFolderPicker, + } else { + // Game folder — single compact row + Row( modifier = - Modifier.size(28.dp).paneNavItem( - cornerRadius = 8.dp, - onActivate = openFolderPicker, - ), + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(Color.White.copy(alpha = 0.05f)) + .padding(horizontal = 10.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, ) { Icon( - Icons.Outlined.Edit, - contentDescription = stringResource(R.string.common_ui_change), - tint = Accent, + Icons.Outlined.Folder, + contentDescription = null, + tint = StatusOnline.copy(alpha = 0.7f), modifier = Modifier.size(14.dp), ) + Spacer(Modifier.width(6.dp)) + Column(Modifier.weight(1f)) { + Text( + stringResource(R.string.library_games_game_folder_mapped_drive), + color = TextSecondary, + fontSize = 9.sp, + ) + Text( + gameFolder ?: stringResource(R.string.common_ui_auto_detected), + color = if (gameFolder != null) TextPrimary else TextSecondary, + fontSize = 10.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + val openFolderPicker = { + if (ensureAllFilesAccessForImports(context)) { + DirectoryPickerDialog.show( + activity = this@UnifiedActivity, + initialPath = gameFolder, + title = getString(R.string.common_ui_select_folder), + dimAmount = 0.5f, + preserveBackdropBlur = true, + extraRoots = driveRoots(includeInternal = true), + ) { path -> gameFolder = path } + } + } + IconButton( + onClick = openFolderPicker, + modifier = + Modifier.size(28.dp).paneNavItem( + cornerRadius = 8.dp, + onActivate = openFolderPicker, + ), + ) { + Icon( + Icons.Outlined.Edit, + contentDescription = stringResource(R.string.common_ui_change), + tint = Accent, + modifier = Modifier.size(14.dp), + ) + } } } } diff --git a/app/src/main/feature/retro/RetroActivity.kt b/app/src/main/feature/retro/RetroActivity.kt new file mode 100644 index 000000000..f54443743 --- /dev/null +++ b/app/src/main/feature/retro/RetroActivity.kt @@ -0,0 +1,353 @@ +package com.winlator.cmod.feature.retro + +import android.os.Bundle +import android.view.InputDevice +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.View +import android.view.WindowManager +import android.widget.FrameLayout +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat +import androidx.lifecycle.lifecycleScope +import com.swordfish.libretrodroid.GLRetroView +import com.swordfish.libretrodroid.GLRetroViewData +import com.swordfish.libretrodroid.ShaderConfig +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import java.io.File + +class RetroActivity : AppCompatActivity(), RetroInputView.Listener { + companion object { + const val EXTRA_ROM_PATH = "retro_rom_path" + const val EXTRA_SYSTEM_ID = "retro_system_id" + const val EXTRA_GAME_NAME = "retro_game_name" + const val EXTRA_SHORTCUT_PATH = "retro_shortcut_path" + const val EXTRA_SHADER = "retro_shader" + const val EXTRA_TOUCH_CONTROLS = "retro_touch_controls" + const val EXTRA_AUDIO = "retro_audio" + } + + private lateinit var retroView: GLRetroView + private var overlay: RetroInputView? = null + private var retroReady = false + private var gameName = "game" + private var fastForward = false + private var audioEnabledSetting = true + private var system: RetroSystem? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + WindowCompat.setDecorFitsSystemWindows(window, false) + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + hideSystemBars() + + val romPath = intent.getStringExtra(EXTRA_ROM_PATH) + val systemId = intent.getStringExtra(EXTRA_SYSTEM_ID) + gameName = intent.getStringExtra(EXTRA_GAME_NAME) ?: "game" + val resolvedSystem = RetroSystems.fromId(systemId) + system = resolvedSystem + + if (romPath.isNullOrBlank() || resolvedSystem == null) { + Toast.makeText(this, "Invalid retro game", Toast.LENGTH_LONG).show() + finish() + return + } + + val romFile = File(romPath) + if (!romFile.isFile) { + Toast.makeText(this, "ROM not found: $romPath", Toast.LENGTH_LONG).show() + finish() + return + } + + val coreFile = RetroCoreManager.coreFile(this, resolvedSystem) + if (!coreFile.isFile) { + Toast.makeText(this, "Core not installed: ${resolvedSystem.coreFileName}", Toast.LENGTH_LONG).show() + finish() + return + } + + if (RetroCoreManager.missingBios(this, resolvedSystem)) { + Toast.makeText( + this, + "${resolvedSystem.shortName} needs a BIOS in ${RetroCoreManager.systemDir(this)}", + Toast.LENGTH_LONG, + ).show() + } + + val savesDir = RetroCoreManager.savesDir(this) + val sramFile = File(savesDir, sramName()) + + val data = + GLRetroViewData(this).apply { + coreFilePath = coreFile.absolutePath + gameFilePath = romFile.absolutePath + systemDirectory = RetroCoreManager.systemDir(this@RetroActivity).absolutePath + savesDirectory = savesDir.absolutePath + shader = shaderFromExtra(intent.getStringExtra(EXTRA_SHADER)) + rumbleEventsEnabled = true + preferLowLatencyAudio = true + if (sramFile.isFile) saveRAMState = runCatching { sramFile.readBytes() }.getOrNull() + } + + audioEnabledSetting = intent.getBooleanExtra(EXTRA_AUDIO, true) + retroView = GLRetroView(this, data) + lifecycle.addObserver(retroView) + + val root = FrameLayout(this) + root.addView( + retroView, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + ), + ) + + if (intent.getBooleanExtra(EXTRA_TOUCH_CONTROLS, true)) { + val inputView = RetroInputView(this, this) + overlay = inputView + root.addView( + inputView, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + ), + ) + } + + setContentView(root) + retroReady = true + observeErrors() + observeEvents() + } + + private fun observeEvents() { + retroView + .getGLRetroEvents() + .onEach { event -> + if (event is GLRetroView.GLRetroEvents.SurfaceCreated && !audioEnabledSetting) { + retroView.audioEnabled = false + } + }.launchIn(lifecycleScope) + } + + private fun observeErrors() { + retroView + .getGLRetroErrors() + .onEach { error -> + val message = + when (error) { + GLRetroView.ERROR_LOAD_LIBRARY -> "Failed to load emulator core" + GLRetroView.ERROR_LOAD_GAME -> "Failed to load ROM" + GLRetroView.ERROR_GL_NOT_COMPATIBLE -> "Graphics not supported for this core" + else -> "Emulator error" + } + Toast.makeText(this@RetroActivity, message, Toast.LENGTH_LONG).show() + finish() + }.launchIn(lifecycleScope) + } + + private fun sramName(): String { + val safe = gameName.replace(Regex("[^A-Za-z0-9._-]"), "_") + return "$safe.srm" + } + + private fun shaderFromExtra(value: String?): ShaderConfig = + when (value?.lowercase()) { + "crt" -> ShaderConfig.CRT + "lcd" -> ShaderConfig.LCD + "sharp" -> ShaderConfig.Sharp + else -> ShaderConfig.Default + } + + private fun mapPhysicalKey(keyCode: Int): Int = + when (keyCode) { + KeyEvent.KEYCODE_BUTTON_A -> KeyEvent.KEYCODE_BUTTON_B + KeyEvent.KEYCODE_BUTTON_B -> KeyEvent.KEYCODE_BUTTON_A + KeyEvent.KEYCODE_BUTTON_X -> KeyEvent.KEYCODE_BUTTON_Y + KeyEvent.KEYCODE_BUTTON_Y -> KeyEvent.KEYCODE_BUTTON_X + else -> keyCode + } + + private fun isGamepadSource(event: KeyEvent): Boolean { + val source = event.device?.sources ?: return false + return source and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD || + source and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK + } + + private val forwardedKeys = + setOf( + KeyEvent.KEYCODE_BUTTON_A, + KeyEvent.KEYCODE_BUTTON_B, + KeyEvent.KEYCODE_BUTTON_X, + KeyEvent.KEYCODE_BUTTON_Y, + KeyEvent.KEYCODE_BUTTON_L1, + KeyEvent.KEYCODE_BUTTON_R1, + KeyEvent.KEYCODE_BUTTON_L2, + KeyEvent.KEYCODE_BUTTON_R2, + KeyEvent.KEYCODE_BUTTON_THUMBL, + KeyEvent.KEYCODE_BUTTON_THUMBR, + KeyEvent.KEYCODE_BUTTON_START, + KeyEvent.KEYCODE_BUTTON_SELECT, + KeyEvent.KEYCODE_DPAD_UP, + KeyEvent.KEYCODE_DPAD_DOWN, + KeyEvent.KEYCODE_DPAD_LEFT, + KeyEvent.KEYCODE_DPAD_RIGHT, + ) + + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + val keyCode = event.keyCode + if (retroReady && isGamepadSource(event)) { + if (keyCode == KeyEvent.KEYCODE_BUTTON_MODE) { + if (event.action == KeyEvent.ACTION_DOWN) openMenu() + return true + } + if (keyCode in forwardedKeys) { + retroView.sendKeyEvent(event.action, mapPhysicalKey(keyCode), 0) + return true + } + } + if (keyCode == KeyEvent.KEYCODE_BACK) { + if (event.action == KeyEvent.ACTION_UP) openMenu() + return true + } + return super.dispatchKeyEvent(event) + } + + override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean { + if (retroReady && + event.action == MotionEvent.ACTION_MOVE && + event.source and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK + ) { + retroView.sendMotionEvent( + GLRetroView.MOTION_SOURCE_DPAD, + event.getAxisValue(MotionEvent.AXIS_HAT_X), + event.getAxisValue(MotionEvent.AXIS_HAT_Y), + 0, + ) + retroView.sendMotionEvent( + GLRetroView.MOTION_SOURCE_ANALOG_LEFT, + event.getAxisValue(MotionEvent.AXIS_X), + event.getAxisValue(MotionEvent.AXIS_Y), + 0, + ) + retroView.sendMotionEvent( + GLRetroView.MOTION_SOURCE_ANALOG_RIGHT, + event.getAxisValue(MotionEvent.AXIS_Z), + event.getAxisValue(MotionEvent.AXIS_RZ), + 0, + ) + return true + } + return super.dispatchGenericMotionEvent(event) + } + + override fun onButton( + keyCode: Int, + down: Boolean, + ) { + if (!retroReady) return + retroView.sendKeyEvent(if (down) KeyEvent.ACTION_DOWN else KeyEvent.ACTION_UP, keyCode, 0) + } + + override fun onDpad( + x: Float, + y: Float, + ) { + if (!retroReady) return + retroView.sendMotionEvent(GLRetroView.MOTION_SOURCE_DPAD, x, y, 0) + } + + override fun onMenu() { + runOnUiThread { openMenu() } + } + + private fun openMenu() { + if (!retroReady) { + finish() + return + } + overlay?.releaseAll() + val ffLabel = if (fastForward) "Fast Forward: On" else "Fast Forward: Off" + val items = arrayOf("Save State", "Load State", "Reset", ffLabel, "Exit") + AlertDialog + .Builder(this) + .setTitle(gameName) + .setItems(items) { _, which -> + when (which) { + 0 -> saveState() + 1 -> loadState() + 2 -> retroView.reset() + 3 -> toggleFastForward() + 4 -> finish() + } + }.setNegativeButton("Resume", null) + .show() + } + + private fun toggleFastForward() { + fastForward = !fastForward + retroView.frameSpeed = if (fastForward) 2 else 1 + } + + private fun saveState() { + runCatching { + val bytes = retroView.serializeState() + RetroCoreManager.stateFile(this, gameName, 0).writeBytes(bytes) + }.onSuccess { + Toast.makeText(this, "State saved", Toast.LENGTH_SHORT).show() + }.onFailure { + Toast.makeText(this, "Could not save state", Toast.LENGTH_SHORT).show() + } + } + + private fun loadState() { + val file = RetroCoreManager.stateFile(this, gameName, 0) + if (!file.isFile) { + Toast.makeText(this, "No saved state", Toast.LENGTH_SHORT).show() + return + } + runCatching { retroView.unserializeState(file.readBytes()) } + .onSuccess { Toast.makeText(this, "State loaded", Toast.LENGTH_SHORT).show() } + .onFailure { Toast.makeText(this, "Could not load state", Toast.LENGTH_SHORT).show() } + } + + private fun persistSram() { + if (!retroReady) return + runCatching { + val sram = retroView.serializeSRAM() + if (sram.isNotEmpty()) { + File(RetroCoreManager.savesDir(this), sramName()).writeBytes(sram) + } + } + } + + override fun onPause() { + persistSram() + super.onPause() + } + + override fun onWindowFocusChanged(hasFocus: Boolean) { + super.onWindowFocusChanged(hasFocus) + if (hasFocus) hideSystemBars() + } + + private fun hideSystemBars() { + val controller = WindowInsetsControllerCompat(window, window.decorView) + controller.hide(WindowInsetsCompat.Type.systemBars()) + controller.systemBarsBehavior = + WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + window.decorView.systemUiVisibility = + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or + View.SYSTEM_UI_FLAG_FULLSCREEN or + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or + View.SYSTEM_UI_FLAG_LAYOUT_STABLE or + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + } +} diff --git a/app/src/main/feature/retro/RetroCoreManager.kt b/app/src/main/feature/retro/RetroCoreManager.kt new file mode 100644 index 000000000..6abe2b071 --- /dev/null +++ b/app/src/main/feature/retro/RetroCoreManager.kt @@ -0,0 +1,41 @@ +package com.winlator.cmod.feature.retro + +import android.content.Context +import java.io.File + +object RetroCoreManager { + fun coreFile( + context: Context, + system: RetroSystem, + ): File = File(context.applicationInfo.nativeLibraryDir, system.coreFileName) + + fun isCoreAvailable( + context: Context, + system: RetroSystem, + ): Boolean = coreFile(context, system).isFile + + fun systemDir(context: Context): File = File(context.filesDir, "retro/system").also { it.mkdirs() } + + fun savesDir(context: Context): File = File(context.filesDir, "retro/saves").also { it.mkdirs() } + + fun statesDir(context: Context): File = File(context.filesDir, "retro/states").also { it.mkdirs() } + + fun stateFile( + context: Context, + gameName: String, + slot: Int, + ): File { + val safe = gameName.replace(Regex("[^A-Za-z0-9._-]"), "_") + val suffix = if (slot <= 0) "state" else "state$slot" + return File(statesDir(context), "$safe.$suffix") + } + + fun missingBios( + context: Context, + system: RetroSystem, + ): Boolean { + if (!system.needsBios) return false + val dir = systemDir(context) + return system.biosFiles.none { File(dir, it).isFile } + } +} diff --git a/app/src/main/feature/retro/RetroGameSettingsDialog.kt b/app/src/main/feature/retro/RetroGameSettingsDialog.kt new file mode 100644 index 000000000..1a6265c05 --- /dev/null +++ b/app/src/main/feature/retro/RetroGameSettingsDialog.kt @@ -0,0 +1,114 @@ +package com.winlator.cmod.feature.retro + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectable +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.winlator.cmod.runtime.container.Shortcut + +private val SHADERS = listOf("default" to "Default", "crt" to "CRT", "lcd" to "LCD", "sharp" to "Sharp") + +@Composable +fun RetroGameSettingsDialog( + shortcut: Shortcut, + onDismiss: () -> Unit, +) { + val system = RetroShortcuts.systemForShortcut(shortcut) + var shader by remember { mutableStateOf(shortcut.getExtra(RetroShortcuts.KEY_SHADER, "default")) } + var touchControls by remember { mutableStateOf(shortcut.getExtra(RetroShortcuts.KEY_TOUCH_CONTROLS, "1") != "0") } + var audio by remember { mutableStateOf(shortcut.getExtra(RetroShortcuts.KEY_AUDIO, "1") != "0") } + + fun persist() { + shortcut.putExtra(RetroShortcuts.KEY_SHADER, shader) + shortcut.putExtra(RetroShortcuts.KEY_TOUCH_CONTROLS, if (touchControls) "1" else "0") + shortcut.putExtra(RetroShortcuts.KEY_AUDIO, if (audio) "1" else "0") + shortcut.saveData() + } + + Dialog(onDismissRequest = { persist(); onDismiss() }) { + androidx.compose.material3.Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surface, + ) { + Column(Modifier.padding(20.dp)) { + Text( + text = system?.displayName ?: "Console Game", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = "Emulator settings", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 12.dp), + ) + + Text( + text = "Video filter", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + SHADERS.forEach { (key, label) -> + Row( + modifier = + Modifier + .fillMaxWidth() + .selectable(selected = shader == key, onClick = { shader = key }) + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = shader == key, onClick = { shader = key }) + Text(label, color = MaterialTheme.colorScheme.onSurface) + } + } + + ToggleRow("On-screen controls", touchControls) { touchControls = it } + ToggleRow("Sound", audio) { audio = it } + + Row( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = { persist(); onDismiss() }) { Text("Done") } + } + } + } + } +} + +@Composable +private fun ToggleRow( + label: String, + checked: Boolean, + onChange: (Boolean) -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { onChange(!checked) } + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(label, color = MaterialTheme.colorScheme.onSurface) + Switch(checked = checked, onCheckedChange = onChange) + } +} diff --git a/app/src/main/feature/retro/RetroInputView.kt b/app/src/main/feature/retro/RetroInputView.kt new file mode 100644 index 000000000..4fd519105 --- /dev/null +++ b/app/src/main/feature/retro/RetroInputView.kt @@ -0,0 +1,266 @@ +package com.winlator.cmod.feature.retro + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF +import android.view.KeyEvent +import android.view.MotionEvent +import android.view.View +import kotlin.math.abs + +class RetroInputView( + context: Context, + private val listener: Listener, +) : View(context) { + interface Listener { + fun onButton( + keyCode: Int, + down: Boolean, + ) + + fun onDpad( + x: Float, + y: Float, + ) + + fun onMenu() + } + + private data class Button( + val keyCode: Int, + val label: String, + var cx: Float = 0f, + var cy: Float = 0f, + var radius: Float = 0f, + ) + + private val faceButtons = + listOf( + Button(KeyEvent.KEYCODE_BUTTON_X, "X"), + Button(KeyEvent.KEYCODE_BUTTON_B, "B"), + Button(KeyEvent.KEYCODE_BUTTON_Y, "Y"), + Button(KeyEvent.KEYCODE_BUTTON_A, "A"), + ) + private val shoulderL = Button(KeyEvent.KEYCODE_BUTTON_L1, "L") + private val shoulderR = Button(KeyEvent.KEYCODE_BUTTON_R1, "R") + private val selectButton = Button(KeyEvent.KEYCODE_BUTTON_SELECT, "SELECT") + private val startButton = Button(KeyEvent.KEYCODE_BUTTON_START, "START") + private val menuButton = Button(0, "MENU") + + private val allButtons + get() = faceButtons + shoulderL + shoulderR + selectButton + startButton + menuButton + + private var dpadCx = 0f + private var dpadCy = 0f + private var dpadRadius = 0f + + private val pressedButtons = HashSet() + private var dpadX = 0f + private var dpadY = 0f + private var menuLatched = false + + private val basePaint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + color = Color.argb(90, 255, 255, 255) + } + private val pressedPaint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.FILL + color = Color.argb(170, 90, 170, 255) + } + private val strokePaint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeWidth = 3f + color = Color.argb(150, 230, 230, 230) + } + private val textPaint = + Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.argb(230, 20, 24, 30) + textAlign = Paint.Align.CENTER + } + + init { + isFocusable = false + isFocusableInTouchMode = false + } + + fun releaseAll() { + for (keyCode in pressedButtons) listener.onButton(keyCode, false) + pressedButtons.clear() + if (dpadX != 0f || dpadY != 0f) { + dpadX = 0f + dpadY = 0f + listener.onDpad(0f, 0f) + } + menuLatched = false + invalidate() + } + + override fun onSizeChanged( + w: Int, + h: Int, + oldw: Int, + oldh: Int, + ) { + super.onSizeChanged(w, h, oldw, oldh) + val width = w.toFloat() + val height = h.toFloat() + val unit = minOf(width, height) + val faceRadius = unit * 0.085f + val margin = unit * 0.06f + + dpadRadius = unit * 0.16f + dpadCx = margin + dpadRadius + dpadCy = height - margin - dpadRadius + + val clusterCx = width - margin - dpadRadius + val clusterCy = height - margin - dpadRadius + val spread = faceRadius * 1.7f + faceButtons.forEach { it.radius = faceRadius } + faceButtons[0].cx = clusterCx + faceButtons[0].cy = clusterCy - spread + faceButtons[1].cx = clusterCx + faceButtons[1].cy = clusterCy + spread + faceButtons[2].cx = clusterCx - spread + faceButtons[2].cy = clusterCy + faceButtons[3].cx = clusterCx + spread + faceButtons[3].cy = clusterCy + + shoulderL.radius = faceRadius + shoulderL.cx = margin + faceRadius + shoulderL.cy = margin + faceRadius + shoulderR.radius = faceRadius + shoulderR.cx = width - margin - faceRadius + shoulderR.cy = margin + faceRadius + + val smallRadius = faceRadius * 0.85f + selectButton.radius = smallRadius + selectButton.cx = width * 0.5f - smallRadius * 1.5f + selectButton.cy = height - margin - smallRadius + startButton.radius = smallRadius + startButton.cx = width * 0.5f + smallRadius * 1.5f + startButton.cy = height - margin - smallRadius + + menuButton.radius = smallRadius + menuButton.cx = width * 0.5f + menuButton.cy = margin + smallRadius + } + + override fun onDraw(canvas: Canvas) { + super.onDraw(canvas) + drawDpad(canvas) + allButtons.forEach { drawButton(canvas, it) } + } + + private fun drawDpad(canvas: Canvas) { + val arm = dpadRadius + val thickness = dpadRadius * 0.66f + val horizontal = + RectF(dpadCx - arm, dpadCy - thickness / 2, dpadCx + arm, dpadCy + thickness / 2) + val vertical = + RectF(dpadCx - thickness / 2, dpadCy - arm, dpadCx + thickness / 2, dpadCy + arm) + val corner = thickness * 0.35f + val activeX = abs(dpadX) > 0.1f + val activeY = abs(dpadY) > 0.1f + canvas.drawRoundRect(horizontal, corner, corner, if (activeX) pressedPaint else basePaint) + canvas.drawRoundRect(vertical, corner, corner, if (activeY) pressedPaint else basePaint) + canvas.drawRoundRect(horizontal, corner, corner, strokePaint) + canvas.drawRoundRect(vertical, corner, corner, strokePaint) + } + + private fun drawButton( + canvas: Canvas, + button: Button, + ) { + val pressed = if (button.keyCode == 0) menuLatched else pressedButtons.contains(button.keyCode) + canvas.drawCircle(button.cx, button.cy, button.radius, if (pressed) pressedPaint else basePaint) + canvas.drawCircle(button.cx, button.cy, button.radius, strokePaint) + textPaint.textSize = button.radius * if (button.label.length > 2) 0.55f else 0.9f + val textY = button.cy - (textPaint.descent() + textPaint.ascent()) / 2 + canvas.drawText(button.label, button.cx, textY, textPaint) + } + + override fun onTouchEvent(event: MotionEvent): Boolean { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN, + MotionEvent.ACTION_POINTER_DOWN, + MotionEvent.ACTION_MOVE, + MotionEvent.ACTION_POINTER_UP, + MotionEvent.ACTION_UP, + MotionEvent.ACTION_CANCEL, + -> recompute(event) + else -> return false + } + invalidate() + return true + } + + private fun recompute(event: MotionEvent) { + val released = event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL + val liftedPointer = + if (event.actionMasked == MotionEvent.ACTION_POINTER_UP) event.actionIndex else -1 + + val newPressed = HashSet() + var newDpadX = 0f + var newDpadY = 0f + var menuTouched = false + + if (!released) { + for (i in 0 until event.pointerCount) { + if (i == liftedPointer) continue + val x = event.getX(i) + val y = event.getY(i) + + val dxToPad = x - dpadCx + val dyToPad = y - dpadCy + val padReach = dpadRadius * 1.5f + if (dxToPad * dxToPad + dyToPad * dyToPad <= padReach * padReach) { + val dz = dpadRadius * 0.28f + if (dxToPad > dz) newDpadX = 1f else if (dxToPad < -dz) newDpadX = -1f + if (dyToPad > dz) newDpadY = 1f else if (dyToPad < -dz) newDpadY = -1f + continue + } + + for (button in allButtons) { + val bdx = x - button.cx + val bdy = y - button.cy + val reach = button.radius * 1.25f + if (bdx * bdx + bdy * bdy <= reach * reach) { + if (button.keyCode == 0) { + menuTouched = true + } else { + newPressed.add(button.keyCode) + } + break + } + } + } + } + + for (keyCode in pressedButtons) { + if (!newPressed.contains(keyCode)) listener.onButton(keyCode, false) + } + for (keyCode in newPressed) { + if (!pressedButtons.contains(keyCode)) listener.onButton(keyCode, true) + } + pressedButtons.clear() + pressedButtons.addAll(newPressed) + + if (newDpadX != dpadX || newDpadY != dpadY) { + dpadX = newDpadX + dpadY = newDpadY + listener.onDpad(dpadX, dpadY) + } + + if (menuTouched && !menuLatched) { + menuLatched = true + listener.onMenu() + } else if (!menuTouched) { + menuLatched = false + } + } +} diff --git a/app/src/main/feature/retro/RetroShortcuts.kt b/app/src/main/feature/retro/RetroShortcuts.kt new file mode 100644 index 000000000..bdb55afa6 --- /dev/null +++ b/app/src/main/feature/retro/RetroShortcuts.kt @@ -0,0 +1,80 @@ +package com.winlator.cmod.feature.retro + +import android.content.Context +import android.content.Intent +import com.winlator.cmod.feature.setup.SetupWizardActivity +import com.winlator.cmod.runtime.container.ContainerManager +import com.winlator.cmod.runtime.container.Shortcut +import com.winlator.cmod.shared.io.FileUtils +import java.io.File +import java.util.UUID + +object RetroShortcuts { + const val KEY_SYSTEM = "retro_system" + const val KEY_ROM = "rom_path" + const val KEY_CORE = "retro_core" + const val KEY_SHADER = "retro_shader" + const val KEY_TOUCH_CONTROLS = "retro_touch_controls" + const val KEY_AUDIO = "retro_audio" + + @JvmStatic + fun isRetroShortcut(shortcut: Shortcut): Boolean = shortcut.getExtra(KEY_SYSTEM).isNotEmpty() + + fun systemForShortcut(shortcut: Shortcut): RetroSystem? = RetroSystems.fromId(shortcut.getExtra(KEY_SYSTEM)) + + fun romPath(shortcut: Shortcut): String = shortcut.getExtra(KEY_ROM) + + fun create( + context: Context, + name: String, + romPath: String, + system: RetroSystem, + ): Boolean { + val containerManager = ContainerManager(context) + val container = SetupWizardActivity.getPreferredGameContainer(context, containerManager) ?: return false + + val desktopDir = container.desktopDir + if (!desktopDir.exists()) desktopDir.mkdirs() + + val safeName = name.replace("/", "_").replace("\\", "_") + val shortcutFile = File(desktopDir, "$safeName.desktop") + val shortcutUuid = UUID.randomUUID().toString() + + val content = + buildString { + append("[Desktop Entry]\n") + append("Type=Application\n") + append("Name=$name\n") + append("Exec=retro:${system.id}\n") + append("Icon=custom_game\n") + append("\n[Extra Data]\n") + append("game_source=CUSTOM\n") + append("custom_name=$name\n") + append("$KEY_SYSTEM=${system.id}\n") + append("$KEY_ROM=$romPath\n") + append("$KEY_CORE=${system.coreFileName}\n") + append("uuid=$shortcutUuid\n") + append("container_id=${container.id}\n") + append("use_container_defaults=1\n") + } + + FileUtils.writeString(shortcutFile, content) + container.saveData() + return true + } + + @JvmStatic + fun launchIntent( + context: Context, + shortcut: Shortcut, + ): Intent = + Intent(context, RetroActivity::class.java).apply { + putExtra(RetroActivity.EXTRA_ROM_PATH, shortcut.getExtra(KEY_ROM)) + putExtra(RetroActivity.EXTRA_SYSTEM_ID, shortcut.getExtra(KEY_SYSTEM)) + putExtra(RetroActivity.EXTRA_GAME_NAME, shortcut.getExtra("custom_name", shortcut.name)) + putExtra(RetroActivity.EXTRA_SHORTCUT_PATH, shortcut.file.absolutePath) + putExtra(RetroActivity.EXTRA_SHADER, shortcut.getExtra(KEY_SHADER, "default")) + putExtra(RetroActivity.EXTRA_TOUCH_CONTROLS, shortcut.getExtra(KEY_TOUCH_CONTROLS, "1") != "0") + putExtra(RetroActivity.EXTRA_AUDIO, shortcut.getExtra(KEY_AUDIO, "1") != "0") + } +} diff --git a/app/src/main/feature/retro/RetroSystem.kt b/app/src/main/feature/retro/RetroSystem.kt new file mode 100644 index 000000000..5d3bb284e --- /dev/null +++ b/app/src/main/feature/retro/RetroSystem.kt @@ -0,0 +1,131 @@ +package com.winlator.cmod.feature.retro + +import java.util.Locale + +data class RetroSystem( + val id: String, + val displayName: String, + val shortName: String, + val coreFileName: String, + val extensions: Set, + val needsBios: Boolean = false, + val biosFiles: List = emptyList(), +) + +object RetroSystems { + val NES = + RetroSystem( + id = "nes", + displayName = "Nintendo Entertainment System", + shortName = "NES", + coreFileName = "libfceumm_libretro_android.so", + extensions = setOf("nes", "unf", "unif"), + ) + val SNES = + RetroSystem( + id = "snes", + displayName = "Super Nintendo", + shortName = "SNES", + coreFileName = "libsnes9x_libretro_android.so", + extensions = setOf("smc", "sfc", "swc", "fig", "bs"), + ) + val GAMEBOY = + RetroSystem( + id = "gb", + displayName = "Game Boy", + shortName = "GB", + coreFileName = "libgambatte_libretro_android.so", + extensions = setOf("gb"), + ) + val GAMEBOY_COLOR = + RetroSystem( + id = "gbc", + displayName = "Game Boy Color", + shortName = "GBC", + coreFileName = "libgambatte_libretro_android.so", + extensions = setOf("gbc"), + ) + val GBA = + RetroSystem( + id = "gba", + displayName = "Game Boy Advance", + shortName = "GBA", + coreFileName = "libmgba_libretro_android.so", + extensions = setOf("gba"), + ) + val GENESIS = + RetroSystem( + id = "genesis", + displayName = "Sega Genesis / Mega Drive", + shortName = "Genesis", + coreFileName = "libgenesis_plus_gx_libretro_android.so", + extensions = setOf("gen", "md", "smd", "bin"), + ) + val MASTER_SYSTEM = + RetroSystem( + id = "sms", + displayName = "Sega Master System", + shortName = "Master System", + coreFileName = "libgenesis_plus_gx_libretro_android.so", + extensions = setOf("sms"), + ) + val GAME_GEAR = + RetroSystem( + id = "gg", + displayName = "Sega Game Gear", + shortName = "Game Gear", + coreFileName = "libgenesis_plus_gx_libretro_android.so", + extensions = setOf("gg"), + ) + val N64 = + RetroSystem( + id = "n64", + displayName = "Nintendo 64", + shortName = "N64", + coreFileName = "libmupen64plus_next_gles3_libretro_android.so", + extensions = setOf("n64", "z64", "v64"), + ) + val PSX = + RetroSystem( + id = "psx", + displayName = "Sony PlayStation", + shortName = "PS1", + coreFileName = "libpcsx_rearmed_libretro_android.so", + extensions = setOf("cue", "chd", "pbp", "m3u", "iso"), + needsBios = false, + biosFiles = listOf("scph5501.bin", "scph5500.bin", "scph5502.bin", "scph1001.bin"), + ) + + val ALL = + listOf(NES, SNES, GAMEBOY, GAMEBOY_COLOR, GBA, GENESIS, MASTER_SYSTEM, GAME_GEAR, N64, PSX) + + private val PSX_ONLY_EXTENSIONS = setOf("cue", "chd", "pbp", "m3u") + + val allExtensions: Set = ALL.flatMap { it.extensions }.toSet() - "exe" + + fun fromId(id: String?): RetroSystem? { + if (id.isNullOrBlank()) return null + val normalized = id.trim().lowercase(Locale.US) + return ALL.firstOrNull { it.id == normalized } + } + + fun fromExtension(extension: String?): RetroSystem? { + if (extension.isNullOrBlank()) return null + val ext = extension.trim().lowercase(Locale.US).removePrefix(".") + if (ext == "exe") return null + if (ext in PSX_ONLY_EXTENSIONS || ext == "iso") return PSX + return ALL.firstOrNull { ext in it.extensions } + } + + fun fromRomPath(path: String?): RetroSystem? { + if (path.isNullOrBlank()) return null + val ext = path.substringAfterLast('.', "") + return fromExtension(ext) + } + + fun isRetroRom(path: String?): Boolean { + if (path.isNullOrBlank()) return false + val ext = path.substringAfterLast('.', "").lowercase(Locale.US) + return ext in allExtensions + } +} diff --git a/app/src/main/feature/shortcuts/ShortcutsFragment.java b/app/src/main/feature/shortcuts/ShortcutsFragment.java index 7924d7cd3..713cc69b2 100644 --- a/app/src/main/feature/shortcuts/ShortcutsFragment.java +++ b/app/src/main/feature/shortcuts/ShortcutsFragment.java @@ -221,6 +221,12 @@ private void runFromShortcut(Shortcut shortcut) { Activity activity = getActivity(); if (activity == null) return; + if (com.winlator.cmod.feature.retro.RetroShortcuts.isRetroShortcut(shortcut)) { + activity.startActivity( + com.winlator.cmod.feature.retro.RetroShortcuts.launchIntent(activity, shortcut)); + return; + } + Intent intent = new Intent(activity, XServerDisplayActivity.class); intent.putExtra("container_id", shortcut.container.id); intent.putExtra("shortcut_path", shortcut.file.getPath()); diff --git a/app/src/main/jniLibs/arm64-v8a/libfceumm_libretro_android.so b/app/src/main/jniLibs/arm64-v8a/libfceumm_libretro_android.so new file mode 100755 index 000000000..62d399674 --- /dev/null +++ b/app/src/main/jniLibs/arm64-v8a/libfceumm_libretro_android.so @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0150618be202d6c727cf2ae937b0170b24346304d4060460f09be530dcf10ea9 +size 4250816 diff --git a/app/src/main/jniLibs/arm64-v8a/libgambatte_libretro_android.so b/app/src/main/jniLibs/arm64-v8a/libgambatte_libretro_android.so new file mode 100755 index 000000000..f4f51a884 --- /dev/null +++ b/app/src/main/jniLibs/arm64-v8a/libgambatte_libretro_android.so @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0023ff382f245fa760af233c02eea9d0af0b66ebd50f89da6cf51d888865a453 +size 4981904 diff --git a/app/src/main/jniLibs/arm64-v8a/libgenesis_plus_gx_libretro_android.so b/app/src/main/jniLibs/arm64-v8a/libgenesis_plus_gx_libretro_android.so new file mode 100755 index 000000000..644c2a4d5 --- /dev/null +++ b/app/src/main/jniLibs/arm64-v8a/libgenesis_plus_gx_libretro_android.so @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d197a4a12983834a1f49fdb7dbaedd94d215a935493e79197cf29c6ca4f07530 +size 12945240 diff --git a/app/src/main/jniLibs/arm64-v8a/libmgba_libretro_android.so b/app/src/main/jniLibs/arm64-v8a/libmgba_libretro_android.so new file mode 100755 index 000000000..e42fb459e --- /dev/null +++ b/app/src/main/jniLibs/arm64-v8a/libmgba_libretro_android.so @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e1eaf81ca43d0473fcfa25d16c99fb4cd545e58ae1eb92616ab268cdecc483ec +size 3168096 diff --git a/app/src/main/jniLibs/arm64-v8a/libmupen64plus_next_gles3_libretro_android.so b/app/src/main/jniLibs/arm64-v8a/libmupen64plus_next_gles3_libretro_android.so new file mode 100755 index 000000000..592b8bb06 --- /dev/null +++ b/app/src/main/jniLibs/arm64-v8a/libmupen64plus_next_gles3_libretro_android.so @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:baf70c367e9ee0576333baf46786cd0d8465d8634dd58c6cff4c5c444ee6b696 +size 6866504 diff --git a/app/src/main/jniLibs/arm64-v8a/libpcsx_rearmed_libretro_android.so b/app/src/main/jniLibs/arm64-v8a/libpcsx_rearmed_libretro_android.so new file mode 100755 index 000000000..603bd406e --- /dev/null +++ b/app/src/main/jniLibs/arm64-v8a/libpcsx_rearmed_libretro_android.so @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3c48e95b3b1535ad94231141a3347b6bbe2c13f71fafcb21150523e9aaf7b9b1 +size 1591688 diff --git a/app/src/main/jniLibs/arm64-v8a/libsnes9x_libretro_android.so b/app/src/main/jniLibs/arm64-v8a/libsnes9x_libretro_android.so new file mode 100755 index 000000000..ffae2be5b --- /dev/null +++ b/app/src/main/jniLibs/arm64-v8a/libsnes9x_libretro_android.so @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f2f6637389d30cb260034f3c61095eb78ee65bfbaaf59d4b8671928312f37880 +size 2911328 diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index c8508b6f3..42accb685 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -1222,6 +1222,14 @@ public void handleOnBackPressed() { if (shortcutPath != null && !shortcutPath.isEmpty()) { shortcut = new Shortcut(container, new File(shortcutPath)); } + + if (shortcut != null + && com.winlator.cmod.feature.retro.RetroShortcuts.isRetroShortcut(shortcut)) { + startActivity(com.winlator.cmod.feature.retro.RetroShortcuts.launchIntent(this, shortcut)); + finish(); + return; + } + loadScreenEffectsSettings(); boolean recordToFile = preferences.getBoolean("hud_record_to_file", false); From 1093ce11cdbdcf42f6e4aed3e87223c1dc4ede7f Mon Sep 17 00:00:00 2001 From: max Date: Mon, 13 Jul 2026 11:31:15 -0400 Subject: [PATCH 02/38] Add in-game retro drawer menu and per-system gated emulator settings - RetroDrawerMenu: xservermenu-style in-game drawer for retro games (BACK, guide button, or on-screen MENU) with Resume, Save/Load State, Reset, Fast Forward, Video Filter, per-core options, Sound, On-screen Controls, Disc changer (multi-disc PSX), and Exit; touch and controller navigable; changes apply live and persist to the shortcut - RetroCoreOptions: per-system core option registry (NES aspect, SNES NTSC filter, GB colorization, GBA color correction, Genesis sprite limit/NTSC, N64 resolution, PSX enhanced resolution/dithering) driving both the in-game drawer and the shortcut settings dialogs - RetroSettingsContent/RetroSettingsDialog: shared per-system settings UI; ShortcutSettingsComposeDialog.show() now routes retro shortcuts to the retro settings dialog so every settings entry point is backend-gated - Core options persist as retro_var_* shortcut extras and are passed to the libretro core at launch --- app/src/main/feature/retro/RetroActivity.kt | 250 ++++++++++++--- .../main/feature/retro/RetroCoreOptions.kt | 114 +++++++ app/src/main/feature/retro/RetroDrawerMenu.kt | 285 ++++++++++++++++++ .../feature/retro/RetroGameSettingsDialog.kt | 142 +++++++-- .../main/feature/retro/RetroSettingsDialog.kt | 88 ++++++ app/src/main/feature/retro/RetroShortcuts.kt | 17 ++ .../ShortcutSettingsComposeDialog.kt | 6 + 7 files changed, 821 insertions(+), 81 deletions(-) create mode 100644 app/src/main/feature/retro/RetroCoreOptions.kt create mode 100644 app/src/main/feature/retro/RetroDrawerMenu.kt create mode 100644 app/src/main/feature/retro/RetroSettingsDialog.kt diff --git a/app/src/main/feature/retro/RetroActivity.kt b/app/src/main/feature/retro/RetroActivity.kt index f54443743..501975448 100644 --- a/app/src/main/feature/retro/RetroActivity.kt +++ b/app/src/main/feature/retro/RetroActivity.kt @@ -8,8 +8,8 @@ import android.view.View import android.view.WindowManager import android.widget.FrameLayout import android.widget.Toast -import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity +import androidx.compose.ui.platform.ComposeView import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat @@ -17,8 +17,14 @@ import androidx.lifecycle.lifecycleScope import com.swordfish.libretrodroid.GLRetroView import com.swordfish.libretrodroid.GLRetroViewData import com.swordfish.libretrodroid.ShaderConfig +import com.swordfish.libretrodroid.Variable +import com.winlator.cmod.runtime.container.ContainerManager +import com.winlator.cmod.runtime.container.Shortcut +import com.winlator.cmod.shared.theme.WinNativeTheme +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import java.io.File class RetroActivity : AppCompatActivity(), RetroInputView.Listener { @@ -27,18 +33,30 @@ class RetroActivity : AppCompatActivity(), RetroInputView.Listener { const val EXTRA_SYSTEM_ID = "retro_system_id" const val EXTRA_GAME_NAME = "retro_game_name" const val EXTRA_SHORTCUT_PATH = "retro_shortcut_path" + const val EXTRA_CONTAINER_ID = "retro_container_id" const val EXTRA_SHADER = "retro_shader" const val EXTRA_TOUCH_CONTROLS = "retro_touch_controls" const val EXTRA_AUDIO = "retro_audio" + const val EXTRA_VARIABLES = "retro_variables" + + private val SHADER_KEYS = listOf("default", "crt", "lcd", "sharp") + private val SHADER_LABELS = listOf("Default", "CRT", "LCD", "Sharp") } private lateinit var retroView: GLRetroView private var overlay: RetroInputView? = null + private val menu = RetroMenuController() private var retroReady = false private var gameName = "game" private var fastForward = false private var audioEnabledSetting = true + private var touchControlsSetting = true + private var currentShaderKey = "default" + private var coreVars = HashMap() + private var diskCount = 0 + private var currentDisk = 0 private var system: RetroSystem? = null + private var persistShortcut: Shortcut? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -82,6 +100,12 @@ class RetroActivity : AppCompatActivity(), RetroInputView.Listener { val savesDir = RetroCoreManager.savesDir(this) val sramFile = File(savesDir, sramName()) + currentShaderKey = intent.getStringExtra(EXTRA_SHADER)?.lowercase() ?: "default" + if (currentShaderKey !in SHADER_KEYS) currentShaderKey = "default" + audioEnabledSetting = intent.getBooleanExtra(EXTRA_AUDIO, true) + touchControlsSetting = intent.getBooleanExtra(EXTRA_TOUCH_CONTROLS, true) + @Suppress("UNCHECKED_CAST", "DEPRECATION") + coreVars = (intent.getSerializableExtra(EXTRA_VARIABLES) as? HashMap) ?: HashMap() val data = GLRetroViewData(this).apply { @@ -89,13 +113,13 @@ class RetroActivity : AppCompatActivity(), RetroInputView.Listener { gameFilePath = romFile.absolutePath systemDirectory = RetroCoreManager.systemDir(this@RetroActivity).absolutePath savesDirectory = savesDir.absolutePath - shader = shaderFromExtra(intent.getStringExtra(EXTRA_SHADER)) + shader = shaderFromKey(currentShaderKey) + variables = coreVars.map { Variable(it.key, it.value) }.toTypedArray() rumbleEventsEnabled = true preferLowLatencyAudio = true if (sramFile.isFile) saveRAMState = runCatching { sramFile.readBytes() }.getOrNull() } - audioEnabledSetting = intent.getBooleanExtra(EXTRA_AUDIO, true) retroView = GLRetroView(this, data) lifecycle.addObserver(retroView) @@ -108,17 +132,36 @@ class RetroActivity : AppCompatActivity(), RetroInputView.Listener { ), ) - if (intent.getBooleanExtra(EXTRA_TOUCH_CONTROLS, true)) { - val inputView = RetroInputView(this, this) - overlay = inputView - root.addView( - inputView, - FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - FrameLayout.LayoutParams.MATCH_PARENT, - ), - ) - } + val inputView = RetroInputView(this, this) + inputView.visibility = if (touchControlsSetting) View.VISIBLE else View.GONE + overlay = inputView + root.addView( + inputView, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + ), + ) + + val menuView = + ComposeView(this).apply { + setContent { + WinNativeTheme { + RetroDrawerMenu( + controller = menu, + title = gameName, + systemLabel = resolvedSystem.displayName, + ) + } + } + } + root.addView( + menuView, + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + ), + ) setContentView(root) retroReady = true @@ -130,8 +173,14 @@ class RetroActivity : AppCompatActivity(), RetroInputView.Listener { retroView .getGLRetroEvents() .onEach { event -> - if (event is GLRetroView.GLRetroEvents.SurfaceCreated && !audioEnabledSetting) { - retroView.audioEnabled = false + if (event is GLRetroView.GLRetroEvents.SurfaceCreated) { + if (!audioEnabledSetting) retroView.audioEnabled = false + lifecycleScope.launch(Dispatchers.Default) { + runCatching { + diskCount = retroView.getAvailableDisks() + currentDisk = retroView.getCurrentDisk() + } + } } }.launchIn(lifecycleScope) } @@ -157,7 +206,7 @@ class RetroActivity : AppCompatActivity(), RetroInputView.Listener { return "$safe.srm" } - private fun shaderFromExtra(value: String?): ShaderConfig = + private fun shaderFromKey(value: String?): ShaderConfig = when (value?.lowercase()) { "crt" -> ShaderConfig.CRT "lcd" -> ShaderConfig.LCD @@ -165,6 +214,132 @@ class RetroActivity : AppCompatActivity(), RetroInputView.Listener { else -> ShaderConfig.Default } + private fun persistExtra( + key: String, + value: String, + ) { + lifecycleScope.launch(Dispatchers.IO) { + runCatching { + val shortcut = + persistShortcut ?: run { + val containerId = intent.getIntExtra(EXTRA_CONTAINER_ID, 0) + val path = intent.getStringExtra(EXTRA_SHORTCUT_PATH) + if (containerId <= 0 || path.isNullOrBlank()) return@run null + val file = File(path) + if (!file.isFile) return@run null + ContainerManager(this@RetroActivity) + .getContainerById(containerId) + ?.let { Shortcut(it, file) } + }?.also { persistShortcut = it } + shortcut?.putExtra(key, value) + shortcut?.saveData() + } + } + } + + private fun buildMenuEntries(): List { + val entries = mutableListOf() + entries += RetroMenuEntry.Action("Resume") { menu.close() } + entries += + RetroMenuEntry.Action("Save State") { + menu.close() + saveState() + } + entries += + RetroMenuEntry.Action("Load State") { + menu.close() + loadState() + } + entries += + RetroMenuEntry.Action("Reset") { + menu.close() + retroView.reset() + } + entries += + RetroMenuEntry.Toggle("Fast Forward", fastForward) { value -> + fastForward = value + retroView.frameSpeed = if (value) 2 else 1 + refreshMenu() + } + + if (diskCount > 1) { + entries += RetroMenuEntry.Header("DISC") + entries += + RetroMenuEntry.Choice("Disc", "${currentDisk + 1} / $diskCount") { direction -> + val next = (currentDisk + direction + diskCount) % diskCount + lifecycleScope.launch(Dispatchers.Default) { + runCatching { retroView.changeDisk(next) } + currentDisk = next + runOnUiThread { refreshMenu() } + } + } + } + + entries += RetroMenuEntry.Header("DISPLAY") + val shaderIndex = SHADER_KEYS.indexOf(currentShaderKey).coerceAtLeast(0) + entries += + RetroMenuEntry.Choice("Video Filter", SHADER_LABELS[shaderIndex]) { direction -> + val next = (shaderIndex + direction + SHADER_KEYS.size) % SHADER_KEYS.size + currentShaderKey = SHADER_KEYS[next] + retroView.shader = shaderFromKey(currentShaderKey) + persistExtra(RetroShortcuts.KEY_SHADER, currentShaderKey) + refreshMenu() + } + + val options = RetroCoreOptions.forSystem(system) + if (options.isNotEmpty()) { + entries += RetroMenuEntry.Header((system?.shortName ?: "CORE").uppercase() + " OPTIONS") + options.forEach { option -> + val current = coreVars[option.key] ?: option.defaultValue + val index = option.values.indexOf(current).coerceAtLeast(0) + entries += + RetroMenuEntry.Choice(option.label, option.valueLabels[index]) { direction -> + val next = (index + direction + option.values.size) % option.values.size + val newValue = option.values[next] + coreVars[option.key] = newValue + retroView.updateVariables(Variable(option.key, newValue)) + persistExtra(RetroShortcuts.VAR_PREFIX + option.key, newValue) + refreshMenu() + } + } + } + + entries += RetroMenuEntry.Header("SOUND") + entries += + RetroMenuEntry.Toggle("Sound", audioEnabledSetting) { value -> + audioEnabledSetting = value + retroView.audioEnabled = value + persistExtra(RetroShortcuts.KEY_AUDIO, if (value) "1" else "0") + refreshMenu() + } + + entries += RetroMenuEntry.Header("CONTROLS") + entries += + RetroMenuEntry.Toggle("On-screen Controls", touchControlsSetting) { value -> + touchControlsSetting = value + overlay?.visibility = if (value) View.VISIBLE else View.GONE + persistExtra(RetroShortcuts.KEY_TOUCH_CONTROLS, if (value) "1" else "0") + refreshMenu() + } + + entries += RetroMenuEntry.Header("") + entries += RetroMenuEntry.Action("Exit Game") { finish() } + return entries + } + + private fun refreshMenu() { + menu.update(buildMenuEntries()) + } + + private fun openMenu() { + if (!retroReady) { + finish() + return + } + overlay?.releaseAll() + menu.open(buildMenuEntries()) + } + private fun mapPhysicalKey(keyCode: Int): Int = when (keyCode) { KeyEvent.KEYCODE_BUTTON_A -> KeyEvent.KEYCODE_BUTTON_B @@ -202,9 +377,13 @@ class RetroActivity : AppCompatActivity(), RetroInputView.Listener { override fun dispatchKeyEvent(event: KeyEvent): Boolean { val keyCode = event.keyCode + if (menu.visible && (isGamepadSource(event) || keyCode == KeyEvent.KEYCODE_BACK)) { + menu.handleKey(keyCode, event.action) + return true + } if (retroReady && isGamepadSource(event)) { if (keyCode == KeyEvent.KEYCODE_BUTTON_MODE) { - if (event.action == KeyEvent.ACTION_DOWN) openMenu() + if (event.action == KeyEvent.ACTION_UP) openMenu() return true } if (keyCode in forwardedKeys) { @@ -220,6 +399,9 @@ class RetroActivity : AppCompatActivity(), RetroInputView.Listener { } override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean { + if (menu.visible && event.source and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK) { + return true + } if (retroReady && event.action == MotionEvent.ACTION_MOVE && event.source and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK @@ -251,7 +433,7 @@ class RetroActivity : AppCompatActivity(), RetroInputView.Listener { keyCode: Int, down: Boolean, ) { - if (!retroReady) return + if (!retroReady || menu.visible) return retroView.sendKeyEvent(if (down) KeyEvent.ACTION_DOWN else KeyEvent.ACTION_UP, keyCode, 0) } @@ -259,7 +441,7 @@ class RetroActivity : AppCompatActivity(), RetroInputView.Listener { x: Float, y: Float, ) { - if (!retroReady) return + if (!retroReady || menu.visible) return retroView.sendMotionEvent(GLRetroView.MOTION_SOURCE_DPAD, x, y, 0) } @@ -267,34 +449,6 @@ class RetroActivity : AppCompatActivity(), RetroInputView.Listener { runOnUiThread { openMenu() } } - private fun openMenu() { - if (!retroReady) { - finish() - return - } - overlay?.releaseAll() - val ffLabel = if (fastForward) "Fast Forward: On" else "Fast Forward: Off" - val items = arrayOf("Save State", "Load State", "Reset", ffLabel, "Exit") - AlertDialog - .Builder(this) - .setTitle(gameName) - .setItems(items) { _, which -> - when (which) { - 0 -> saveState() - 1 -> loadState() - 2 -> retroView.reset() - 3 -> toggleFastForward() - 4 -> finish() - } - }.setNegativeButton("Resume", null) - .show() - } - - private fun toggleFastForward() { - fastForward = !fastForward - retroView.frameSpeed = if (fastForward) 2 else 1 - } - private fun saveState() { runCatching { val bytes = retroView.serializeState() diff --git a/app/src/main/feature/retro/RetroCoreOptions.kt b/app/src/main/feature/retro/RetroCoreOptions.kt new file mode 100644 index 000000000..d09418c82 --- /dev/null +++ b/app/src/main/feature/retro/RetroCoreOptions.kt @@ -0,0 +1,114 @@ +package com.winlator.cmod.feature.retro + +data class RetroCoreOption( + val key: String, + val label: String, + val values: List, + val valueLabels: List, + val defaultValue: String, +) + +object RetroCoreOptions { + private val NES_OPTIONS = + listOf( + RetroCoreOption( + key = "fceumm_aspect", + label = "Aspect Ratio", + values = listOf("8:7 PAR", "4:3"), + valueLabels = listOf("8:7 (Native)", "4:3 (TV)"), + defaultValue = "8:7 PAR", + ), + ) + + private val SNES_OPTIONS = + listOf( + RetroCoreOption( + key = "snes9x_blargg", + label = "NTSC Filter", + values = listOf("disabled", "rf", "composite", "s-video", "rgb"), + valueLabels = listOf("Off", "RF", "Composite", "S-Video", "RGB"), + defaultValue = "disabled", + ), + ) + + private val GB_OPTIONS = + listOf( + RetroCoreOption( + key = "gambatte_gb_colorization", + label = "Colorization", + values = listOf("disabled", "auto"), + valueLabels = listOf("Off", "Auto"), + defaultValue = "disabled", + ), + ) + + private val GBA_OPTIONS = + listOf( + RetroCoreOption( + key = "mgba_color_correction", + label = "Color Correction", + values = listOf("OFF", "GBA", "GBC"), + valueLabels = listOf("Off", "GBA Screen", "GBC Screen"), + defaultValue = "OFF", + ), + ) + + private val GENESIS_OPTIONS = + listOf( + RetroCoreOption( + key = "genesis_plus_gx_no_sprite_limit", + label = "Remove Sprite Limit", + values = listOf("disabled", "enabled"), + valueLabels = listOf("Off", "On"), + defaultValue = "disabled", + ), + RetroCoreOption( + key = "genesis_plus_gx_blargg_ntsc_filter", + label = "NTSC Filter", + values = listOf("Off", "Composite", "S-Video", "RGB"), + valueLabels = listOf("Off", "Composite", "S-Video", "RGB"), + defaultValue = "Off", + ), + ) + + private val N64_OPTIONS = + listOf( + RetroCoreOption( + key = "mupen64plus-43screensize", + label = "Resolution", + values = listOf("320x240", "640x480", "960x720"), + valueLabels = listOf("320x240 (Native)", "640x480", "960x720"), + defaultValue = "320x240", + ), + ) + + private val PSX_OPTIONS = + listOf( + RetroCoreOption( + key = "pcsx_rearmed_neon_enhancement_enable", + label = "Enhanced Resolution", + values = listOf("disabled", "enabled"), + valueLabels = listOf("Off", "On (2x)"), + defaultValue = "disabled", + ), + RetroCoreOption( + key = "pcsx_rearmed_dithering", + label = "Dithering", + values = listOf("enabled", "disabled"), + valueLabels = listOf("On", "Off"), + defaultValue = "enabled", + ), + ) + + fun forSystem(system: RetroSystem?): List = + when (system?.id) { + RetroSystems.NES.id -> NES_OPTIONS + RetroSystems.SNES.id -> SNES_OPTIONS + RetroSystems.GAMEBOY.id -> GB_OPTIONS + RetroSystems.GBA.id -> GBA_OPTIONS + RetroSystems.GENESIS.id, RetroSystems.MASTER_SYSTEM.id, RetroSystems.GAME_GEAR.id -> GENESIS_OPTIONS + RetroSystems.N64.id -> N64_OPTIONS + RetroSystems.PSX.id -> PSX_OPTIONS + else -> emptyList() + } +} diff --git a/app/src/main/feature/retro/RetroDrawerMenu.kt b/app/src/main/feature/retro/RetroDrawerMenu.kt new file mode 100644 index 000000000..f7b4d8222 --- /dev/null +++ b/app/src/main/feature/retro/RetroDrawerMenu.kt @@ -0,0 +1,285 @@ +package com.winlator.cmod.feature.retro + +import android.view.KeyEvent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +sealed class RetroMenuEntry { + class Header( + val label: String, + ) : RetroMenuEntry() + + class Action( + val label: String, + val onClick: () -> Unit, + ) : RetroMenuEntry() + + class Toggle( + val label: String, + val checked: Boolean, + val onChange: (Boolean) -> Unit, + ) : RetroMenuEntry() + + class Choice( + val label: String, + val valueLabel: String, + val onCycle: (Int) -> Unit, + ) : RetroMenuEntry() +} + +class RetroMenuController { + var visible by mutableStateOf(false) + private set + var entries by mutableStateOf>(emptyList()) + private set + var focusIndex by mutableIntStateOf(0) + + fun open(items: List) { + entries = items + focusIndex = items.indexOfFirst { it !is RetroMenuEntry.Header }.coerceAtLeast(0) + visible = true + } + + fun update(items: List) { + entries = items + if (focusIndex >= items.size) { + focusIndex = items.indexOfFirst { it !is RetroMenuEntry.Header }.coerceAtLeast(0) + } + } + + fun close() { + visible = false + } + + private fun moveFocus(direction: Int) { + if (entries.isEmpty()) return + var index = focusIndex + repeat(entries.size) { + index = (index + direction + entries.size) % entries.size + if (entries[index] !is RetroMenuEntry.Header) { + focusIndex = index + return + } + } + } + + private fun activate(direction: Int) { + when (val entry = entries.getOrNull(focusIndex)) { + is RetroMenuEntry.Action -> if (direction == 0) entry.onClick() + is RetroMenuEntry.Toggle -> entry.onChange(!entry.checked) + is RetroMenuEntry.Choice -> entry.onCycle(if (direction < 0) -1 else 1) + else -> {} + } + } + + fun handleKey( + keyCode: Int, + action: Int, + ): Boolean { + if (!visible) return false + val handled = + keyCode in + setOf( + KeyEvent.KEYCODE_DPAD_UP, + KeyEvent.KEYCODE_DPAD_DOWN, + KeyEvent.KEYCODE_DPAD_LEFT, + KeyEvent.KEYCODE_DPAD_RIGHT, + KeyEvent.KEYCODE_DPAD_CENTER, + KeyEvent.KEYCODE_BUTTON_A, + KeyEvent.KEYCODE_BUTTON_B, + KeyEvent.KEYCODE_BACK, + KeyEvent.KEYCODE_BUTTON_MODE, + KeyEvent.KEYCODE_BUTTON_START, + ) + if (!handled) return false + if (action == KeyEvent.ACTION_DOWN) { + when (keyCode) { + KeyEvent.KEYCODE_DPAD_UP -> moveFocus(-1) + KeyEvent.KEYCODE_DPAD_DOWN -> moveFocus(1) + KeyEvent.KEYCODE_DPAD_LEFT -> activate(-1) + KeyEvent.KEYCODE_DPAD_RIGHT -> activate(1) + KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_BUTTON_A -> activate(0) + } + } else if (action == KeyEvent.ACTION_UP) { + when (keyCode) { + KeyEvent.KEYCODE_BUTTON_B, KeyEvent.KEYCODE_BACK, + KeyEvent.KEYCODE_BUTTON_MODE, KeyEvent.KEYCODE_BUTTON_START, + -> close() + } + } + return true + } +} + +private val DrawerBackground = Color(0xF0141B24) +private val DrawerFocus = Color(0xFF0E2438) +private val DrawerAccent = Color(0xFF29B6F6) +private val DrawerText = Color(0xFFE6EDF3) +private val DrawerTextDim = Color(0xFF8B949E) + +@Composable +fun RetroDrawerMenu( + controller: RetroMenuController, + title: String, + systemLabel: String, +) { + AnimatedVisibility( + visible = controller.visible, + enter = fadeIn(), + exit = fadeOut(), + ) { + Box( + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.35f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { controller.close() }, + ) + } + AnimatedVisibility( + visible = controller.visible, + enter = slideInHorizontally(initialOffsetX = { -it }), + exit = slideOutHorizontally(targetOffsetX = { -it }), + ) { + Column( + Modifier + .width(320.dp) + .fillMaxHeight() + .background(DrawerBackground) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) {}, + ) { + Column(Modifier.padding(horizontal = 18.dp, vertical = 14.dp)) { + Text( + title, + color = DrawerText, + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + maxLines = 1, + ) + Text(systemLabel, color = DrawerAccent, fontSize = 11.sp) + } + val listState = rememberLazyListState() + LaunchedEffect(controller.focusIndex, controller.visible) { + if (controller.visible && controller.entries.isNotEmpty()) { + listState.animateScrollToItem( + controller.focusIndex.coerceIn(0, controller.entries.size - 1), + ) + } + } + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = + androidx.compose.foundation.layout + .PaddingValues(bottom = 24.dp), + ) { + itemsIndexed(controller.entries) { index, entry -> + when (entry) { + is RetroMenuEntry.Header -> + Text( + entry.label, + color = DrawerTextDim, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(start = 18.dp, top = 14.dp, bottom = 4.dp), + ) + is RetroMenuEntry.Action -> + MenuRow( + focused = index == controller.focusIndex, + onClick = { controller.focusIndex = index; entry.onClick() }, + ) { + Text(entry.label, color = DrawerText, fontSize = 13.sp) + } + is RetroMenuEntry.Toggle -> + MenuRow( + focused = index == controller.focusIndex, + onClick = { controller.focusIndex = index; entry.onChange(!entry.checked) }, + ) { + Text(entry.label, color = DrawerText, fontSize = 13.sp) + Text( + if (entry.checked) "On" else "Off", + color = if (entry.checked) DrawerAccent else DrawerTextDim, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + ) + } + is RetroMenuEntry.Choice -> + MenuRow( + focused = index == controller.focusIndex, + onClick = { controller.focusIndex = index; entry.onCycle(1) }, + ) { + Text(entry.label, color = DrawerText, fontSize = 13.sp) + Text( + entry.valueLabel, + color = DrawerAccent, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + ) + } + } + } + } + } + } +} + +@Composable +private fun MenuRow( + focused: Boolean, + onClick: () -> Unit, + content: @Composable () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 2.dp) + .clip(RoundedCornerShape(10.dp)) + .background(if (focused) DrawerFocus else Color.Transparent) + .clickable(onClick = onClick) + .padding(horizontal = 10.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + content() + } +} diff --git a/app/src/main/feature/retro/RetroGameSettingsDialog.kt b/app/src/main/feature/retro/RetroGameSettingsDialog.kt index 1a6265c05..389d46f91 100644 --- a/app/src/main/feature/retro/RetroGameSettingsDialog.kt +++ b/app/src/main/feature/retro/RetroGameSettingsDialog.kt @@ -5,8 +5,11 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme import androidx.compose.material3.RadioButton import androidx.compose.material3.Switch @@ -14,6 +17,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -26,69 +30,141 @@ import com.winlator.cmod.runtime.container.Shortcut private val SHADERS = listOf("default" to "Default", "crt" to "CRT", "lcd" to "LCD", "sharp" to "Sharp") @Composable -fun RetroGameSettingsDialog( +fun RetroSettingsContent( shortcut: Shortcut, - onDismiss: () -> Unit, + onDone: () -> Unit, ) { val system = RetroShortcuts.systemForShortcut(shortcut) + val coreOptions = remember(system?.id) { RetroCoreOptions.forSystem(system) } var shader by remember { mutableStateOf(shortcut.getExtra(RetroShortcuts.KEY_SHADER, "default")) } var touchControls by remember { mutableStateOf(shortcut.getExtra(RetroShortcuts.KEY_TOUCH_CONTROLS, "1") != "0") } var audio by remember { mutableStateOf(shortcut.getExtra(RetroShortcuts.KEY_AUDIO, "1") != "0") } + val optionValues = + remember(system?.id) { + mutableStateMapOf().apply { + coreOptions.forEach { option -> + put( + option.key, + shortcut + .getExtra(RetroShortcuts.VAR_PREFIX + option.key) + .ifEmpty { option.defaultValue }, + ) + } + } + } fun persist() { shortcut.putExtra(RetroShortcuts.KEY_SHADER, shader) shortcut.putExtra(RetroShortcuts.KEY_TOUCH_CONTROLS, if (touchControls) "1" else "0") shortcut.putExtra(RetroShortcuts.KEY_AUDIO, if (audio) "1" else "0") + coreOptions.forEach { option -> + val value = optionValues[option.key] ?: option.defaultValue + shortcut.putExtra(RetroShortcuts.VAR_PREFIX + option.key, value) + } shortcut.saveData() } - Dialog(onDismissRequest = { persist(); onDismiss() }) { - androidx.compose.material3.Surface( - shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surface, + Column( + Modifier + .padding(20.dp) + .heightIn(max = 560.dp), + ) { + Text( + text = system?.displayName ?: "Console Game", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = "Emulator settings", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 12.dp), + ) + + Column( + Modifier + .weight(1f, fill = false) + .verticalScroll(rememberScrollState()), ) { - Column(Modifier.padding(20.dp)) { - Text( - text = system?.displayName ?: "Console Game", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = "Emulator settings", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 12.dp), - ) + Text( + text = "Video filter", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + SHADERS.forEach { (key, label) -> + Row( + modifier = + Modifier + .fillMaxWidth() + .selectable(selected = shader == key, onClick = { shader = key }) + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = shader == key, onClick = { shader = key }) + Text(label, color = MaterialTheme.colorScheme.onSurface) + } + } + if (coreOptions.isNotEmpty()) { Text( - text = "Video filter", + text = "${system?.shortName ?: "Core"} options", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(top = 12.dp, bottom = 4.dp), ) - SHADERS.forEach { (key, label) -> + coreOptions.forEach { option -> + val current = optionValues[option.key] ?: option.defaultValue + val currentIndex = option.values.indexOf(current).coerceAtLeast(0) Row( modifier = Modifier .fillMaxWidth() - .selectable(selected = shader == key, onClick = { shader = key }) - .padding(vertical = 4.dp), + .clickable { + optionValues[option.key] = + option.values[(currentIndex + 1) % option.values.size] + }.padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, ) { - RadioButton(selected = shader == key, onClick = { shader = key }) - Text(label, color = MaterialTheme.colorScheme.onSurface) + Text(option.label, color = MaterialTheme.colorScheme.onSurface) + Text( + option.valueLabels[currentIndex], + color = MaterialTheme.colorScheme.primary, + ) } } + } - ToggleRow("On-screen controls", touchControls) { touchControls = it } - ToggleRow("Sound", audio) { audio = it } + Text( + text = "General", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.padding(top = 12.dp, bottom = 4.dp), + ) + ToggleRow("On-screen controls", touchControls) { touchControls = it } + ToggleRow("Sound", audio) { audio = it } + } - Row( - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - horizontalArrangement = Arrangement.End, - ) { - TextButton(onClick = { persist(); onDismiss() }) { Text("Done") } - } - } + Row( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = { persist(); onDone() }) { Text("Done") } + } + } +} + +@Composable +fun RetroGameSettingsDialog( + shortcut: Shortcut, + onDismiss: () -> Unit, +) { + Dialog(onDismissRequest = onDismiss) { + androidx.compose.material3.Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surface, + ) { + RetroSettingsContent(shortcut = shortcut, onDone = onDismiss) } } } diff --git a/app/src/main/feature/retro/RetroSettingsDialog.kt b/app/src/main/feature/retro/RetroSettingsDialog.kt new file mode 100644 index 000000000..bfb044b21 --- /dev/null +++ b/app/src/main/feature/retro/RetroSettingsDialog.kt @@ -0,0 +1,88 @@ +package com.winlator.cmod.feature.retro + +import android.app.Activity +import android.app.Dialog +import android.view.ViewGroup +import android.view.Window +import android.view.WindowManager +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.unit.dp +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.setViewTreeLifecycleOwner +import androidx.savedstate.SavedStateRegistryOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner +import com.winlator.cmod.runtime.container.Shortcut +import com.winlator.cmod.shared.theme.WinNativeTheme + +class RetroSettingsDialog( + private val activity: Activity, + private val shortcut: Shortcut, +) { + private val dialog: Dialog = + Dialog(activity).apply { + requestWindowFeature(Window.FEATURE_NO_TITLE) + setCancelable(true) + setOwnerActivity(activity) + window?.apply { + setBackgroundDrawableResource(android.R.color.transparent) + setLayout( + WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.MATCH_PARENT, + ) + setDimAmount(0.5f) + addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) + } + } + + init { + val composeView = + ComposeView(activity).apply { + layoutParams = + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + setViewTreeLifecycleOwner(activity as LifecycleOwner) + setViewTreeSavedStateRegistryOwner(activity as SavedStateRegistryOwner) + setContent { + WinNativeTheme { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surface, + modifier = Modifier.widthIn(max = 420.dp), + ) { + RetroSettingsContent( + shortcut = shortcut, + onDone = { dialog.dismiss() }, + ) + } + } + } + } + } + dialog.setContentView(composeView) + (activity as LifecycleOwner).lifecycle.addObserver( + object : DefaultLifecycleObserver { + override fun onDestroy(owner: LifecycleOwner) { + if (dialog.isShowing) dialog.dismiss() + } + }, + ) + } + + fun show() { + dialog.show() + } +} diff --git a/app/src/main/feature/retro/RetroShortcuts.kt b/app/src/main/feature/retro/RetroShortcuts.kt index bdb55afa6..64ab91dd0 100644 --- a/app/src/main/feature/retro/RetroShortcuts.kt +++ b/app/src/main/feature/retro/RetroShortcuts.kt @@ -16,6 +16,21 @@ object RetroShortcuts { const val KEY_SHADER = "retro_shader" const val KEY_TOUCH_CONTROLS = "retro_touch_controls" const val KEY_AUDIO = "retro_audio" + const val VAR_PREFIX = "retro_var_" + + fun coreVariables(shortcut: Shortcut): HashMap { + val vars = HashMap() + val extras = shortcut.extraData + val keys = extras.keys() + while (keys.hasNext()) { + val key = keys.next() + if (key.startsWith(VAR_PREFIX)) { + val value = shortcut.getExtra(key) + if (value.isNotEmpty()) vars[key.removePrefix(VAR_PREFIX)] = value + } + } + return vars + } @JvmStatic fun isRetroShortcut(shortcut: Shortcut): Boolean = shortcut.getExtra(KEY_SYSTEM).isNotEmpty() @@ -73,8 +88,10 @@ object RetroShortcuts { putExtra(RetroActivity.EXTRA_SYSTEM_ID, shortcut.getExtra(KEY_SYSTEM)) putExtra(RetroActivity.EXTRA_GAME_NAME, shortcut.getExtra("custom_name", shortcut.name)) putExtra(RetroActivity.EXTRA_SHORTCUT_PATH, shortcut.file.absolutePath) + putExtra(RetroActivity.EXTRA_CONTAINER_ID, shortcut.container.id) putExtra(RetroActivity.EXTRA_SHADER, shortcut.getExtra(KEY_SHADER, "default")) putExtra(RetroActivity.EXTRA_TOUCH_CONTROLS, shortcut.getExtra(KEY_TOUCH_CONTROLS, "1") != "0") putExtra(RetroActivity.EXTRA_AUDIO, shortcut.getExtra(KEY_AUDIO, "1") != "0") + putExtra(RetroActivity.EXTRA_VARIABLES, coreVariables(shortcut)) } } diff --git a/app/src/main/feature/shortcuts/ShortcutSettingsComposeDialog.kt b/app/src/main/feature/shortcuts/ShortcutSettingsComposeDialog.kt index 05604ba5d..ca7530306 100644 --- a/app/src/main/feature/shortcuts/ShortcutSettingsComposeDialog.kt +++ b/app/src/main/feature/shortcuts/ShortcutSettingsComposeDialog.kt @@ -2303,6 +2303,12 @@ class ShortcutSettingsComposeDialog private constructor( // Show / Dismiss fun show() { + if (com.winlator.cmod.feature.retro.RetroShortcuts.isRetroShortcut(shortcut)) { + com.winlator.cmod.feature.retro + .RetroSettingsDialog(activity, shortcut) + .show() + return + } dialog.show() restorePaneNav?.invoke() restorePaneNav = dialog.window?.bindPaneNav( From 31a0074cb9aa06028e5549247507611865964e78 Mon Sep 17 00:00:00 2001 From: max Date: Mon, 13 Jul 2026 11:53:49 -0400 Subject: [PATCH 03/38] Restyle retro in-game drawer and settings to match the PC surfaces - RetroDrawerMenu rebuilt in the xservermenu style: left ModalDrawer-style sheet (300dp, rounded, same sheet/rail colors), top rail tabs with animated underline indicator (Menu / Display / / Sound / Controls, gated per system), action-card grid for quick actions, danger-styled bottom Exit tile, and region-based controller navigation (rail / content / bottom) - Retro shortcut settings rebuilt in the PC GameSettings style: same ContentDialog window (dim, blur, sizing), sidebar tabs with icons and Save/Cancel footer, animated section transitions, identical colors and row components, per-system gated sections (General / Graphics / Options / Input / Audio) - Controller navigation reuses the shared GameSettingsNav + bindPaneNav + PaneNavRegistry machinery so tabs, rows, and dropdown overlays navigate exactly like the PC settings dialog - Settings edits are now buffered with explicit Save/Cancel like PC games --- app/src/main/app/shell/UnifiedActivity.kt | 21 +- app/src/main/feature/retro/RetroActivity.kt | 140 ++- app/src/main/feature/retro/RetroDrawerMenu.kt | 922 +++++++++++++++--- .../main/feature/retro/RetroGameSettings.kt | 719 ++++++++++++++ .../feature/retro/RetroGameSettingsDialog.kt | 190 ---- .../main/feature/retro/RetroSettingsDialog.kt | 91 +- 6 files changed, 1632 insertions(+), 451 deletions(-) create mode 100644 app/src/main/feature/retro/RetroGameSettings.kt delete mode 100644 app/src/main/feature/retro/RetroGameSettingsDialog.kt diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index ca616f2dc..625921198 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -5686,22 +5686,17 @@ class UnifiedActivity : val epicId = if (isEpic) app.id - 2000000000 else 0 var retroSystemId by remember(app.id) { mutableStateOf(null) } - var retroSettingsShortcut by remember(app.id) { mutableStateOf(null) } - var showRetroSettings by remember(app.id) { mutableStateOf(false) } LaunchedEffect(app.id, isCustom) { if (isCustom) { withContext(Dispatchers.IO) { val sc = findLibraryShortcutForGame(ContainerManager(context), app, true, false, 0) - val sys = + retroSystemId = sc ?.getExtra(com.winlator.cmod.feature.retro.RetroShortcuts.KEY_SYSTEM) ?.takeIf { it.isNotEmpty() } - retroSystemId = sys - retroSettingsShortcut = if (sys != null) sc else null } } else { retroSystemId = null - retroSettingsShortcut = null } } val isRetro = retroSystemId != null @@ -6372,8 +6367,9 @@ class UnifiedActivity : if (shortcut != null && com.winlator.cmod.feature.retro.RetroShortcuts.isRetroShortcut(shortcut) ) { - retroSettingsShortcut = shortcut - showRetroSettings = true + com.winlator.cmod.feature.retro + .RetroSettingsDialog(this@UnifiedActivity, shortcut) + .show() } else if (shortcut != null) { // Layer the settings dialog on top; keep the detail dialog open underneath. ShortcutSettingsComposeDialog(this@UnifiedActivity, shortcut).show() @@ -6490,15 +6486,6 @@ class UnifiedActivity : onWorkshop = { if (!isEpic && !isGog) showWorkshopDialog = true }, ) - if (showRetroSettings) { - retroSettingsShortcut?.let { retroShortcut -> - com.winlator.cmod.feature.retro.RetroGameSettingsDialog( - shortcut = retroShortcut, - onDismiss = { showRetroSettings = false }, - ) - } - } - when (heroPopup) { HeroLaunchPopup.BootToDesktop -> HeroBootDialog( diff --git a/app/src/main/feature/retro/RetroActivity.kt b/app/src/main/feature/retro/RetroActivity.kt index 501975448..919af845f 100644 --- a/app/src/main/feature/retro/RetroActivity.kt +++ b/app/src/main/feature/retro/RetroActivity.kt @@ -143,15 +143,14 @@ class RetroActivity : AppCompatActivity(), RetroInputView.Listener { ), ) + menu.entriesProvider = { pane -> buildEntriesFor(pane) } + menu.tabs = RetroDrawerTabs.build(resolvedSystem, RetroCoreOptions.forSystem(resolvedSystem).isNotEmpty()) + menu.onExit = { finish() } val menuView = ComposeView(this).apply { setContent { WinNativeTheme { - RetroDrawerMenu( - controller = menu, - title = gameName, - systemLabel = resolvedSystem.displayName, - ) + RetroDrawerMenu(menu) } } } @@ -237,107 +236,100 @@ class RetroActivity : AppCompatActivity(), RetroInputView.Listener { } } - private fun buildMenuEntries(): List { + private fun buildEntriesFor(pane: RetroPane?): List = + when (pane) { + null -> buildMainEntries() + RetroPane.DISPLAY -> + SHADER_KEYS.mapIndexed { index, key -> + RetroMenuEntry.Radio( + label = SHADER_LABELS[index], + selected = currentShaderKey == key, + ) { + currentShaderKey = key + retroView.shader = shaderFromKey(key) + persistExtra(RetroShortcuts.KEY_SHADER, key) + menu.rebuild() + } + } + RetroPane.SYSTEM -> + RetroCoreOptions.forSystem(system).map { option -> + val current = coreVars[option.key] ?: option.defaultValue + val index = option.values.indexOf(current).coerceAtLeast(0) + RetroMenuEntry.Choice(option.label, option.valueLabels[index]) { direction -> + val next = (index + direction + option.values.size) % option.values.size + val newValue = option.values[next] + coreVars[option.key] = newValue + retroView.updateVariables(Variable(option.key, newValue)) + persistExtra(RetroShortcuts.VAR_PREFIX + option.key, newValue) + menu.rebuild() + } + } + RetroPane.SOUND -> + listOf( + RetroMenuEntry.Toggle("Sound", checked = audioEnabledSetting) { value -> + audioEnabledSetting = value + retroView.audioEnabled = value + persistExtra(RetroShortcuts.KEY_AUDIO, if (value) "1" else "0") + menu.rebuild() + }, + ) + RetroPane.CONTROLS -> + listOf( + RetroMenuEntry.Toggle("On-screen Controls", checked = touchControlsSetting) { value -> + touchControlsSetting = value + overlay?.visibility = if (value) View.VISIBLE else View.GONE + persistExtra(RetroShortcuts.KEY_TOUCH_CONTROLS, if (value) "1" else "0") + menu.rebuild() + }, + ) + } + + private fun buildMainEntries(): List { val entries = mutableListOf() - entries += RetroMenuEntry.Action("Resume") { menu.close() } entries += - RetroMenuEntry.Action("Save State") { + RetroMenuEntry.Action("Resume", RetroDrawerIcons.Resume) { menu.close() } + entries += + RetroMenuEntry.Action("Save State", RetroDrawerIcons.Save) { menu.close() saveState() } entries += - RetroMenuEntry.Action("Load State") { + RetroMenuEntry.Action("Load State", RetroDrawerIcons.Load) { menu.close() loadState() } entries += - RetroMenuEntry.Action("Reset") { + RetroMenuEntry.Action("Reset", RetroDrawerIcons.Reset) { menu.close() retroView.reset() } entries += - RetroMenuEntry.Toggle("Fast Forward", fastForward) { value -> - fastForward = value - retroView.frameSpeed = if (value) 2 else 1 - refreshMenu() + RetroMenuEntry.Action("Fast Forward", RetroDrawerIcons.FastForward, active = fastForward) { + fastForward = !fastForward + retroView.frameSpeed = if (fastForward) 2 else 1 + menu.rebuild() } - if (diskCount > 1) { - entries += RetroMenuEntry.Header("DISC") entries += - RetroMenuEntry.Choice("Disc", "${currentDisk + 1} / $diskCount") { direction -> - val next = (currentDisk + direction + diskCount) % diskCount + RetroMenuEntry.Action("Disc ${currentDisk + 1}/$diskCount", RetroDrawerIcons.Disc) { + val next = (currentDisk + 1) % diskCount lifecycleScope.launch(Dispatchers.Default) { runCatching { retroView.changeDisk(next) } currentDisk = next - runOnUiThread { refreshMenu() } + runOnUiThread { menu.rebuild() } } } } - - entries += RetroMenuEntry.Header("DISPLAY") - val shaderIndex = SHADER_KEYS.indexOf(currentShaderKey).coerceAtLeast(0) - entries += - RetroMenuEntry.Choice("Video Filter", SHADER_LABELS[shaderIndex]) { direction -> - val next = (shaderIndex + direction + SHADER_KEYS.size) % SHADER_KEYS.size - currentShaderKey = SHADER_KEYS[next] - retroView.shader = shaderFromKey(currentShaderKey) - persistExtra(RetroShortcuts.KEY_SHADER, currentShaderKey) - refreshMenu() - } - - val options = RetroCoreOptions.forSystem(system) - if (options.isNotEmpty()) { - entries += RetroMenuEntry.Header((system?.shortName ?: "CORE").uppercase() + " OPTIONS") - options.forEach { option -> - val current = coreVars[option.key] ?: option.defaultValue - val index = option.values.indexOf(current).coerceAtLeast(0) - entries += - RetroMenuEntry.Choice(option.label, option.valueLabels[index]) { direction -> - val next = (index + direction + option.values.size) % option.values.size - val newValue = option.values[next] - coreVars[option.key] = newValue - retroView.updateVariables(Variable(option.key, newValue)) - persistExtra(RetroShortcuts.VAR_PREFIX + option.key, newValue) - refreshMenu() - } - } - } - - entries += RetroMenuEntry.Header("SOUND") - entries += - RetroMenuEntry.Toggle("Sound", audioEnabledSetting) { value -> - audioEnabledSetting = value - retroView.audioEnabled = value - persistExtra(RetroShortcuts.KEY_AUDIO, if (value) "1" else "0") - refreshMenu() - } - - entries += RetroMenuEntry.Header("CONTROLS") - entries += - RetroMenuEntry.Toggle("On-screen Controls", touchControlsSetting) { value -> - touchControlsSetting = value - overlay?.visibility = if (value) View.VISIBLE else View.GONE - persistExtra(RetroShortcuts.KEY_TOUCH_CONTROLS, if (value) "1" else "0") - refreshMenu() - } - - entries += RetroMenuEntry.Header("") - entries += RetroMenuEntry.Action("Exit Game") { finish() } return entries } - private fun refreshMenu() { - menu.update(buildMenuEntries()) - } - private fun openMenu() { if (!retroReady) { finish() return } overlay?.releaseAll() - menu.open(buildMenuEntries()) + menu.open() } private fun mapPhysicalKey(keyCode: Int): Int = diff --git a/app/src/main/feature/retro/RetroDrawerMenu.kt b/app/src/main/feature/retro/RetroDrawerMenu.kt index f7b4d8222..e19054550 100644 --- a/app/src/main/feature/retro/RetroDrawerMenu.kt +++ b/app/src/main/feature/retro/RetroDrawerMenu.kt @@ -1,55 +1,137 @@ package com.winlator.cmod.feature.retro import android.view.KeyEvent +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInHorizontally -import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ExitToApp +import androidx.compose.material.icons.automirrored.outlined.VolumeUp +import androidx.compose.material.icons.outlined.Album +import androidx.compose.material.icons.outlined.Apps +import androidx.compose.material.icons.outlined.Download +import androidx.compose.material.icons.outlined.FastForward +import androidx.compose.material.icons.outlined.Monitor +import androidx.compose.material.icons.outlined.PlayArrow +import androidx.compose.material.icons.outlined.RestartAlt +import androidx.compose.material.icons.outlined.Save +import androidx.compose.material.icons.outlined.SportsEsports +import androidx.compose.material.icons.outlined.Tune +import androidx.compose.material3.Icon +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.boundsInParent +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.winlator.cmod.shared.theme.WinNativeBackground +import com.winlator.cmod.shared.theme.WinNativeOutline +import com.winlator.cmod.shared.theme.WinNativePanel +import com.winlator.cmod.shared.theme.WinNativeSurface +import com.winlator.cmod.shared.theme.WinNativeTextPrimary +import com.winlator.cmod.shared.theme.WinNativeTextSecondary +import com.winlator.cmod.shared.ui.outlinedSwitchColors +import com.winlator.cmod.shared.ui.widget.chasingBorder -sealed class RetroMenuEntry { - class Header( - val label: String, - ) : RetroMenuEntry() +private const val DrawerSheetAlpha = 0.86f +private const val DrawerSurfaceAlpha = 0.72f +private const val DrawerPressedAlpha = 0.88f +private const val DrawerGradientLift = 0.014f + +private val DrawerAccent = Color(0xFF2196F3) +private val DrawerActiveAccent = Color(0xFF29B6F6) +private val DrawerFocusFill = Color(0xFF0E2438) +private val DrawerTextPrimary = WinNativeTextPrimary.copy(alpha = 0.88f) +private val DrawerTextSecondary = WinNativeTextSecondary.copy(alpha = 0.82f) +private val RetroSheetColor = WinNativeBackground.copy(alpha = DrawerSheetAlpha) +private val TopRailSurfaceColor = WinNativeSurface.copy(alpha = DrawerSheetAlpha) +private val PaneSurfacePressed = Color(0xFF232B3A).copy(alpha = DrawerPressedAlpha) +private val PaneInnerResting = WinNativePanel.copy(alpha = DrawerSurfaceAlpha) +private val PaneInnerPressed = Color(0xFF242B3A).copy(alpha = DrawerPressedAlpha) +private val TileExitResting = Color(0xFF3A2125).copy(alpha = DrawerSurfaceAlpha) +private val TileExitPressed = Color(0xFF4A2A30).copy(alpha = DrawerPressedAlpha) +private val RestingCardBorder = WinNativeOutline.copy(alpha = 0.72f) +private val ActiveCardBorder = DrawerActiveAccent +private val GlassExitTint = Color(0xFFE07B6B) +private val DividerColor = WinNativeOutline.copy(alpha = 0.6f) + +private val DrawerWidth = 300.dp +private val DrawerStartPadding = 6.dp +private val DrawerVerticalPadding = 6.dp + +enum class RetroPane { DISPLAY, SYSTEM, SOUND, CONTROLS } +data class RetroTabSpec( + val pane: RetroPane?, + val icon: ImageVector, + val label: String, +) + +sealed class RetroMenuEntry { class Action( val label: String, + val icon: ImageVector, + val active: Boolean = false, + val danger: Boolean = false, val onClick: () -> Unit, ) : RetroMenuEntry() class Toggle( val label: String, + val subtitle: String? = null, val checked: Boolean, val onChange: (Boolean) -> Unit, ) : RetroMenuEntry() @@ -59,227 +141,773 @@ sealed class RetroMenuEntry { val valueLabel: String, val onCycle: (Int) -> Unit, ) : RetroMenuEntry() + + class Radio( + val label: String, + val selected: Boolean, + val onSelect: () -> Unit, + ) : RetroMenuEntry() } class RetroMenuController { var visible by mutableStateOf(false) private set + var pane by mutableStateOf(null) + private set + var region by mutableIntStateOf(1) + var railIndex by mutableIntStateOf(0) + var contentIndex by mutableIntStateOf(0) + var controllerActive by mutableStateOf(false) + var tabs by mutableStateOf>(emptyList()) var entries by mutableStateOf>(emptyList()) private set - var focusIndex by mutableIntStateOf(0) + var entriesProvider: ((RetroPane?) -> List)? = null + var onExit: (() -> Unit)? = null - fun open(items: List) { - entries = items - focusIndex = items.indexOfFirst { it !is RetroMenuEntry.Header }.coerceAtLeast(0) - visible = true - } + val gridColumns: Int get() = if (pane == null) 3 else 1 - fun update(items: List) { - entries = items - if (focusIndex >= items.size) { - focusIndex = items.indexOfFirst { it !is RetroMenuEntry.Header }.coerceAtLeast(0) - } + fun open() { + pane = null + railIndex = 0 + region = 1 + contentIndex = 0 + controllerActive = false + rebuild() + visible = true } fun close() { visible = false } - private fun moveFocus(direction: Int) { - if (entries.isEmpty()) return - var index = focusIndex - repeat(entries.size) { - index = (index + direction + entries.size) % entries.size - if (entries[index] !is RetroMenuEntry.Header) { - focusIndex = index - return - } - } + fun showPane(target: RetroPane?) { + pane = target + rebuild() + region = 1 + contentIndex = 0 + railIndex = tabs.indexOfFirst { it.pane == target }.coerceAtLeast(0) + } + + fun rebuild() { + entries = entriesProvider?.invoke(pane) ?: emptyList() + if (contentIndex >= entries.size) contentIndex = (entries.size - 1).coerceAtLeast(0) } private fun activate(direction: Int) { - when (val entry = entries.getOrNull(focusIndex)) { + when (val entry = entries.getOrNull(contentIndex)) { is RetroMenuEntry.Action -> if (direction == 0) entry.onClick() is RetroMenuEntry.Toggle -> entry.onChange(!entry.checked) is RetroMenuEntry.Choice -> entry.onCycle(if (direction < 0) -1 else 1) + is RetroMenuEntry.Radio -> if (direction == 0) entry.onSelect() else -> {} } } + private fun moveContent(delta: Int) { + if (entries.isEmpty()) return + val next = contentIndex + delta + when { + next < 0 -> region = 0 + next >= entries.size && pane == null -> region = 2 + next in entries.indices -> contentIndex = next + } + } + fun handleKey( keyCode: Int, action: Int, ): Boolean { if (!visible) return false - val handled = - keyCode in - setOf( - KeyEvent.KEYCODE_DPAD_UP, - KeyEvent.KEYCODE_DPAD_DOWN, - KeyEvent.KEYCODE_DPAD_LEFT, - KeyEvent.KEYCODE_DPAD_RIGHT, - KeyEvent.KEYCODE_DPAD_CENTER, - KeyEvent.KEYCODE_BUTTON_A, - KeyEvent.KEYCODE_BUTTON_B, - KeyEvent.KEYCODE_BACK, - KeyEvent.KEYCODE_BUTTON_MODE, - KeyEvent.KEYCODE_BUTTON_START, - ) - if (!handled) return false + val navKeys = + setOf( + KeyEvent.KEYCODE_DPAD_UP, + KeyEvent.KEYCODE_DPAD_DOWN, + KeyEvent.KEYCODE_DPAD_LEFT, + KeyEvent.KEYCODE_DPAD_RIGHT, + KeyEvent.KEYCODE_DPAD_CENTER, + KeyEvent.KEYCODE_BUTTON_A, + KeyEvent.KEYCODE_BUTTON_B, + KeyEvent.KEYCODE_BACK, + KeyEvent.KEYCODE_BUTTON_MODE, + KeyEvent.KEYCODE_BUTTON_START, + ) + if (keyCode !in navKeys) return true if (action == KeyEvent.ACTION_DOWN) { + controllerActive = true when (keyCode) { - KeyEvent.KEYCODE_DPAD_UP -> moveFocus(-1) - KeyEvent.KEYCODE_DPAD_DOWN -> moveFocus(1) - KeyEvent.KEYCODE_DPAD_LEFT -> activate(-1) - KeyEvent.KEYCODE_DPAD_RIGHT -> activate(1) - KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_BUTTON_A -> activate(0) + KeyEvent.KEYCODE_DPAD_UP -> + when (region) { + 1 -> if (pane == null) moveContent(-gridColumns) else moveContent(-1) + 2 -> region = 1 + } + KeyEvent.KEYCODE_DPAD_DOWN -> + when (region) { + 0 -> if (entries.isNotEmpty()) region = 1 + 1 -> if (pane == null) moveContent(gridColumns) else moveContent(1) + } + KeyEvent.KEYCODE_DPAD_LEFT -> + when (region) { + 0 -> railIndex = (railIndex - 1 + tabs.size) % tabs.size + 1 -> if (pane == null) moveContent(-1) else activate(-1) + } + KeyEvent.KEYCODE_DPAD_RIGHT -> + when (region) { + 0 -> railIndex = (railIndex + 1) % tabs.size + 1 -> if (pane == null) moveContent(1) else activate(1) + } + KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_BUTTON_A -> + when (region) { + 0 -> tabs.getOrNull(railIndex)?.let { showPane(it.pane) } + 1 -> activate(0) + 2 -> onExit?.invoke() + } } } else if (action == KeyEvent.ACTION_UP) { when (keyCode) { KeyEvent.KEYCODE_BUTTON_B, KeyEvent.KEYCODE_BACK, KeyEvent.KEYCODE_BUTTON_MODE, KeyEvent.KEYCODE_BUTTON_START, - -> close() + -> if (pane != null) showPane(null) else close() } } return true } } -private val DrawerBackground = Color(0xF0141B24) -private val DrawerFocus = Color(0xFF0E2438) -private val DrawerAccent = Color(0xFF29B6F6) -private val DrawerText = Color(0xFFE6EDF3) -private val DrawerTextDim = Color(0xFF8B949E) - @Composable -fun RetroDrawerMenu( - controller: RetroMenuController, - title: String, - systemLabel: String, -) { +fun RetroDrawerMenu(controller: RetroMenuController) { + val density = LocalDensity.current AnimatedVisibility( visible = controller.visible, - enter = fadeIn(), - exit = fadeOut(), + enter = fadeIn(tween(180)), + exit = fadeOut(tween(140)), ) { Box( Modifier .fillMaxSize() - .background(Color.Black.copy(alpha = 0.35f)) + .background(Color.Black.copy(alpha = 0.32f)) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, ) { controller.close() }, ) } - AnimatedVisibility( - visible = controller.visible, - enter = slideInHorizontally(initialOffsetX = { -it }), - exit = slideOutHorizontally(targetOffsetX = { -it }), - ) { - Column( + + val closedOffset = -(DrawerWidth + DrawerStartPadding + 8.dp) + val sheetOffset by animateDpAsState( + targetValue = if (controller.visible) 0.dp else closedOffset, + animationSpec = tween(durationMillis = 200, easing = LinearEasing), + label = "retroDrawerOffset", + ) + if (sheetOffset > closedOffset) { + Box( Modifier - .width(320.dp) + .padding(start = DrawerStartPadding, top = DrawerVerticalPadding, bottom = DrawerVerticalPadding) .fillMaxHeight() - .background(DrawerBackground) + .width(DrawerWidth) + .offset { androidx.compose.ui.unit.IntOffset(with(density) { sheetOffset.roundToPx() }, 0) } + .clip(RoundedCornerShape(20.dp)) + .background(RetroSheetColor) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, ) {}, ) { - Column(Modifier.padding(horizontal = 18.dp, vertical = 14.dp)) { - Text( - title, - color = DrawerText, - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - maxLines = 1, + Column(Modifier.fillMaxSize()) { + RetroTopRail(controller) + ThinDivider() + Box(Modifier.weight(1f).fillMaxWidth()) { + AnimatedContent( + targetState = controller.pane, + transitionSpec = { + fadeIn(tween(220, easing = FastOutSlowInEasing)) togetherWith + fadeOut(tween(220, easing = FastOutSlowInEasing)) + }, + label = "retroDrawerBody", + ) { pane -> + if (pane == null) { + RetroActionGrid(controller) + } else { + RetroPaneList(controller) + } + } + } + AnimatedVisibility(visible = controller.pane == null) { + Column { + ThinDivider() + RetroBottomExit(controller) + } + } + } + } + } +} + +private data class RailTileBounds( + val offsetX: Float, + val width: Float, + val height: Float, +) + +@Composable +private fun RetroTopRail(controller: RetroMenuController) { + val density = LocalDensity.current + val tileBounds = remember { mutableStateMapOf() } + val railScroll = rememberScrollState() + + val selectedIndex = + if (controller.controllerActive && controller.region == 0) { + controller.railIndex + } else { + controller.tabs.indexOfFirst { it.pane == controller.pane }.coerceAtLeast(0) + } + val selectedBounds = tileBounds[selectedIndex] + + val indicatorAnimSpec = tween(durationMillis = 240, easing = FastOutSlowInEasing) + val indicatorX by animateDpAsState( + targetValue = selectedBounds?.let { with(density) { it.offsetX.toDp() } } ?: 0.dp, + animationSpec = indicatorAnimSpec, + label = "retroRailX", + ) + val indicatorWidth by animateDpAsState( + targetValue = selectedBounds?.let { with(density) { it.width.toDp() } } ?: 0.dp, + animationSpec = indicatorAnimSpec, + label = "retroRailW", + ) + val indicatorTileHeight by animateDpAsState( + targetValue = selectedBounds?.let { with(density) { it.height.toDp() } } ?: 0.dp, + animationSpec = indicatorAnimSpec, + label = "retroRailH", + ) + val indicatorAlpha by animateFloatAsState( + targetValue = if (selectedBounds != null) 1f else 0f, + animationSpec = tween(160), + label = "retroRailA", + ) + + Box( + modifier = + Modifier + .fillMaxWidth() + .background(TopRailSurfaceColor) + .padding(start = 10.dp, end = 10.dp, top = 5.dp, bottom = 2.dp), + ) { + if (selectedBounds != null) { + Box( + modifier = + Modifier + .offset( + x = indicatorX - with(density) { railScroll.value.toDp() } + 6.dp, + y = indicatorTileHeight - 2.dp, + ) + .width((indicatorWidth - 12.dp).coerceAtLeast(0.dp)) + .height(2.dp) + .graphicsLayer { alpha = indicatorAlpha } + .clip(RoundedCornerShape(1.dp)) + .background(DrawerAccent), + ) + } + Row( + modifier = Modifier.horizontalScroll(railScroll), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + controller.tabs.forEachIndexed { index, tab -> + RetroRailTile( + icon = tab.icon, + label = tab.label, + selected = + if (controller.controllerActive && controller.region == 0) { + controller.railIndex == index + } else { + controller.pane == tab.pane + }, + highlighted = controller.controllerActive && controller.region == 0 && controller.railIndex == index, + onBoundsChanged = { tileBounds[index] = it }, + onClick = { + controller.railIndex = index + controller.showPane(tab.pane) + }, ) - Text(systemLabel, color = DrawerAccent, fontSize = 11.sp) } - val listState = rememberLazyListState() - LaunchedEffect(controller.focusIndex, controller.visible) { - if (controller.visible && controller.entries.isNotEmpty()) { - listState.animateScrollToItem( - controller.focusIndex.coerceIn(0, controller.entries.size - 1), - ) + } + } +} + +@Composable +private fun RetroRailTile( + icon: ImageVector, + label: String, + selected: Boolean, + highlighted: Boolean, + onBoundsChanged: (RailTileBounds) -> Unit, + onClick: () -> Unit, +) { + val interactionSource = remember { MutableInteractionSource() } + val pressed = interactionSource.collectIsPressedAsState().value + val scale by animateFloatAsState( + targetValue = if (pressed) 0.94f else 1f, + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMedium), + label = "retroTileScale", + ) + val bgColor by animateColorAsState( + targetValue = + when { + highlighted -> DrawerFocusFill + pressed && !selected -> PaneSurfacePressed + else -> Color.Transparent + }, + animationSpec = tween(120), + label = "retroTileBg", + ) + val tint by animateColorAsState( + targetValue = if (selected) DrawerAccent else DrawerTextPrimary, + animationSpec = tween(120), + label = "retroTileTint", + ) + val shape = RoundedCornerShape(12.dp) + Column( + modifier = + Modifier + .defaultMinSize(minWidth = 60.dp) + .onGloballyPositioned { coords -> + val bounds = coords.boundsInParent() + onBoundsChanged(RailTileBounds(bounds.left, bounds.width, bounds.height)) } - } - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize(), - contentPadding = - androidx.compose.foundation.layout - .PaddingValues(bottom = 24.dp), + .graphicsLayer { + scaleX = scale + scaleY = scale + } + .clip(shape) + .background(bgColor) + .then( + if (highlighted) { + Modifier.chasingBorder(cornerRadius = 12.dp, borderWidth = 1.5.dp, animationDurationMs = 8200) + } else { + Modifier + }, + ) + .clickable(interactionSource = interactionSource, indication = null, onClick = onClick) + .padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 7.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon(imageVector = icon, contentDescription = label, tint = tint, modifier = Modifier.size(22.dp)) + Spacer(Modifier.height(2.dp)) + Text( + text = label, + color = DrawerTextPrimary, + fontSize = 12.sp, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium, + letterSpacing = 0.2.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun RetroActionGrid(controller: RetroMenuController) { + val actions = controller.entries + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 10.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + actions.chunked(controller.gridColumns).forEachIndexed { rowIndex, row -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - itemsIndexed(controller.entries) { index, entry -> - when (entry) { - is RetroMenuEntry.Header -> - Text( - entry.label, - color = DrawerTextDim, - fontSize = 11.sp, - fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(start = 18.dp, top = 14.dp, bottom = 4.dp), - ) - is RetroMenuEntry.Action -> - MenuRow( - focused = index == controller.focusIndex, - onClick = { controller.focusIndex = index; entry.onClick() }, - ) { - Text(entry.label, color = DrawerText, fontSize = 13.sp) - } - is RetroMenuEntry.Toggle -> - MenuRow( - focused = index == controller.focusIndex, - onClick = { controller.focusIndex = index; entry.onChange(!entry.checked) }, - ) { - Text(entry.label, color = DrawerText, fontSize = 13.sp) - Text( - if (entry.checked) "On" else "Off", - color = if (entry.checked) DrawerAccent else DrawerTextDim, - fontSize = 13.sp, - fontWeight = FontWeight.SemiBold, - ) - } - is RetroMenuEntry.Choice -> - MenuRow( - focused = index == controller.focusIndex, - onClick = { controller.focusIndex = index; entry.onCycle(1) }, - ) { - Text(entry.label, color = DrawerText, fontSize = 13.sp) - Text( - entry.valueLabel, - color = DrawerAccent, - fontSize = 13.sp, - fontWeight = FontWeight.SemiBold, - ) - } + row.forEachIndexed { colIndex, entry -> + val flatIndex = rowIndex * controller.gridColumns + colIndex + if (entry is RetroMenuEntry.Action) { + RetroActionCard( + entry = entry, + highlighted = + controller.controllerActive && + controller.region == 1 && + controller.contentIndex == flatIndex, + modifier = Modifier.weight(1f).height(64.dp), + onClick = { + controller.contentIndex = flatIndex + entry.onClick() + }, + ) } } + repeat(controller.gridColumns - row.size) { + Spacer(Modifier.weight(1f)) + } } } } } @Composable -private fun MenuRow( - focused: Boolean, +private fun RetroActionCard( + entry: RetroMenuEntry.Action, + highlighted: Boolean, + modifier: Modifier = Modifier, onClick: () -> Unit, - content: @Composable () -> Unit, ) { + val interactionSource = remember { MutableInteractionSource() } + val pressed = interactionSource.collectIsPressedAsState().value + val scale by animateFloatAsState( + targetValue = if (pressed) 0.96f else 1f, + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMedium), + label = "retroCardScale", + ) + val bgColor by animateColorAsState( + targetValue = + when { + highlighted -> DrawerFocusFill + pressed -> PaneInnerPressed + else -> PaneInnerResting + }, + animationSpec = tween(120), + label = "retroCardBg", + ) + val borderColor by animateColorAsState( + targetValue = if (entry.active) ActiveCardBorder else RestingCardBorder, + animationSpec = tween(120), + label = "retroCardBorder", + ) + val tint = + when { + entry.danger -> GlassExitTint + entry.active -> DrawerActiveAccent + else -> DrawerTextPrimary + } + val shape = RoundedCornerShape(12.dp) + val topColor = + Color( + red = (bgColor.red + (1f - bgColor.red) * DrawerGradientLift).coerceIn(0f, 1f), + green = (bgColor.green + (1f - bgColor.green) * DrawerGradientLift).coerceIn(0f, 1f), + blue = (bgColor.blue + (1f - bgColor.blue) * DrawerGradientLift).coerceIn(0f, 1f), + alpha = bgColor.alpha, + ) + Column( + modifier = + modifier + .graphicsLayer { + scaleX = scale + scaleY = scale + } + .clip(shape) + .background(Brush.verticalGradient(listOf(topColor, bgColor))) + .border(1.dp, borderColor, shape) + .then( + if (highlighted) { + Modifier.chasingBorder(cornerRadius = 12.dp, borderWidth = 1.5.dp, animationDurationMs = 8200) + } else { + Modifier + }, + ) + .clickable(interactionSource = interactionSource, indication = null, onClick = onClick), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon(imageVector = entry.icon, contentDescription = entry.label, tint = tint, modifier = Modifier.size(24.dp)) + Spacer(Modifier.height(4.dp)) + Text( + text = entry.label, + color = DrawerTextPrimary, + fontSize = 12.sp, + fontWeight = if (entry.active) FontWeight.SemiBold else FontWeight.Medium, + letterSpacing = 0.2.sp, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun RetroPaneList(controller: RetroMenuController) { + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 10.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + controller.entries.forEachIndexed { index, entry -> + val highlighted = + controller.controllerActive && + controller.region == 1 && + controller.contentIndex == index + when (entry) { + is RetroMenuEntry.Toggle -> + RetroBooleanRow( + entry = entry, + highlighted = highlighted, + onClick = { + controller.contentIndex = index + entry.onChange(!entry.checked) + }, + ) + is RetroMenuEntry.Choice -> + RetroChoiceRow( + entry = entry, + highlighted = highlighted, + onClick = { + controller.contentIndex = index + entry.onCycle(1) + }, + ) + is RetroMenuEntry.Radio -> + RetroRadioRow( + entry = entry, + highlighted = highlighted, + onClick = { + controller.contentIndex = index + entry.onSelect() + }, + ) + is RetroMenuEntry.Action -> + RetroActionCard( + entry = entry, + highlighted = highlighted, + modifier = Modifier.fillMaxWidth().height(56.dp), + onClick = { + controller.contentIndex = index + entry.onClick() + }, + ) + } + } + } +} + +@Composable +private fun RetroRowShell( + highlighted: Boolean, + activeBorder: Boolean, + onClick: () -> Unit, + content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit, +) { + val interactionSource = remember { MutableInteractionSource() } + val pressed = interactionSource.collectIsPressedAsState().value + val bgColor by animateColorAsState( + targetValue = + when { + highlighted -> DrawerFocusFill + pressed -> PaneInnerPressed + else -> PaneInnerResting + }, + animationSpec = tween(140), + label = "retroRowBg", + ) + val borderColor by animateColorAsState( + targetValue = if (activeBorder) ActiveCardBorder else RestingCardBorder, + animationSpec = tween(140), + label = "retroRowBorder", + ) + val shape = RoundedCornerShape(14.dp) Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 10.dp, vertical = 2.dp) - .clip(RoundedCornerShape(10.dp)) - .background(if (focused) DrawerFocus else Color.Transparent) - .clickable(onClick = onClick) - .padding(horizontal = 10.dp, vertical = 10.dp), + .clip(shape) + .background(bgColor) + .border(1.dp, borderColor, shape) + .then( + if (highlighted) { + Modifier.chasingBorder(cornerRadius = 14.dp, borderWidth = 1.5.dp, animationDurationMs = 8200) + } else { + Modifier + }, + ) + .clickable(interactionSource = interactionSource, indication = null, onClick = onClick) + .padding(horizontal = 12.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, ) { content() } } + +@Composable +private fun RetroBooleanRow( + entry: RetroMenuEntry.Toggle, + highlighted: Boolean, + onClick: () -> Unit, +) { + RetroRowShell(highlighted = highlighted, activeBorder = entry.checked, onClick = onClick) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = entry.label, + color = DrawerTextPrimary, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + ) + Spacer(Modifier.height(2.dp)) + Text( + text = entry.subtitle ?: if (entry.checked) "Enabled" else "Disabled", + color = DrawerTextSecondary, + fontSize = 12.sp, + ) + } + Switch( + checked = entry.checked, + onCheckedChange = entry.onChange, + colors = outlinedSwitchColors(DrawerAccent, DrawerTextSecondary), + ) + } +} + +@Composable +private fun RetroChoiceRow( + entry: RetroMenuEntry.Choice, + highlighted: Boolean, + onClick: () -> Unit, +) { + RetroRowShell(highlighted = highlighted, activeBorder = false, onClick = onClick) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = entry.label, + color = DrawerTextPrimary, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + ) + Spacer(Modifier.height(2.dp)) + Text( + text = entry.valueLabel, + color = DrawerActiveAccent, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + ) + } + Text( + text = "‹ ›", + color = DrawerTextSecondary, + fontSize = 16.sp, + ) + } +} + +@Composable +private fun RetroRadioRow( + entry: RetroMenuEntry.Radio, + highlighted: Boolean, + onClick: () -> Unit, +) { + RetroRowShell(highlighted = highlighted, activeBorder = entry.selected, onClick = onClick) { + Text( + text = entry.label, + color = if (entry.selected) DrawerActiveAccent else DrawerTextPrimary, + fontSize = 14.sp, + fontWeight = if (entry.selected) FontWeight.SemiBold else FontWeight.Medium, + modifier = Modifier.weight(1f), + ) + if (entry.selected) { + Text( + text = "✓", + color = DrawerActiveAccent, + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + ) + } + } +} + +@Composable +private fun RetroBottomExit(controller: RetroMenuController) { + val interactionSource = remember { MutableInteractionSource() } + val pressed = interactionSource.collectIsPressedAsState().value + val highlighted = controller.controllerActive && controller.region == 2 + val bgColor by animateColorAsState( + targetValue = + when { + highlighted -> DrawerFocusFill + pressed -> TileExitPressed + else -> TileExitResting + }, + animationSpec = tween(120), + label = "retroExitBg", + ) + val shape = RoundedCornerShape(14.dp) + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 8.dp), + ) { + Row( + modifier = + Modifier + .weight(1f) + .clip(shape) + .background(bgColor) + .border(1.dp, GlassExitTint.copy(alpha = 0.34f), shape) + .then( + if (highlighted) { + Modifier.chasingBorder(cornerRadius = 14.dp, borderWidth = 1.5.dp, animationDurationMs = 8200) + } else { + Modifier + }, + ) + .clickable(interactionSource = interactionSource, indication = null) { + controller.onExit?.invoke() + } + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ExitToApp, + contentDescription = null, + tint = GlassExitTint, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text( + text = "Exit", + color = GlassExitTint, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + ) + } + } +} + +@Composable +private fun ThinDivider() { + Box( + modifier = + Modifier + .fillMaxWidth() + .height(1.dp) + .background(DividerColor), + ) +} + +object RetroDrawerTabs { + fun build( + system: RetroSystem?, + hasCoreOptions: Boolean, + ): List { + val tabs = mutableListOf() + tabs += RetroTabSpec(null, Icons.Outlined.Apps, "Menu") + tabs += RetroTabSpec(RetroPane.DISPLAY, Icons.Outlined.Monitor, "Display") + if (hasCoreOptions) { + tabs += RetroTabSpec(RetroPane.SYSTEM, Icons.Outlined.Tune, system?.shortName ?: "System") + } + tabs += RetroTabSpec(RetroPane.SOUND, Icons.AutoMirrored.Outlined.VolumeUp, "Sound") + tabs += RetroTabSpec(RetroPane.CONTROLS, Icons.Outlined.SportsEsports, "Controls") + return tabs + } +} + +object RetroDrawerIcons { + val Resume = Icons.Outlined.PlayArrow + val Save = Icons.Outlined.Save + val Load = Icons.Outlined.Download + val Reset = Icons.Outlined.RestartAlt + val FastForward = Icons.Outlined.FastForward + val Disc = Icons.Outlined.Album +} diff --git a/app/src/main/feature/retro/RetroGameSettings.kt b/app/src/main/feature/retro/RetroGameSettings.kt new file mode 100644 index 000000000..8d186ec9a --- /dev/null +++ b/app/src/main/feature/retro/RetroGameSettings.kt @@ -0,0 +1,719 @@ +package com.winlator.cmod.feature.retro + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.core.tween +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.VolumeUp +import androidx.compose.material.icons.outlined.KeyboardArrowDown +import androidx.compose.material.icons.outlined.Memory +import androidx.compose.material.icons.outlined.Monitor +import androidx.compose.material.icons.outlined.SportsEsports +import androidx.compose.material.icons.outlined.Tune +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.PopupProperties +import com.winlator.cmod.feature.library.GameSettingsNav +import com.winlator.cmod.runtime.container.Shortcut +import com.winlator.cmod.shared.ui.nav.LocalPaneNav +import com.winlator.cmod.shared.ui.nav.PaneNavRegistry +import com.winlator.cmod.shared.ui.nav.paneHighlight +import com.winlator.cmod.shared.ui.nav.paneNavItem +import com.winlator.cmod.shared.ui.outlinedSwitchColors +import com.winlator.cmod.shared.ui.widget.chasingBorder + +private val BgDeep = Color(0xFF11111C) +private val SidebarBg = Color(0xFF11111C) +private val ContentBg = Color(0xFF11111C) +private val CardSurface = Color(0xFF1C1C2A) +private val CardBorder = Color(0xFF2A2A3A) +private val InputSurface = Color(0xFF171722) +private val InputBorder = Color(0xFF2A2A3A) +private val AccentBlue = Color(0xFF1A9FFF) +private val TextPrimary = Color(0xFFF0F4FF) +private val TextSecondary = Color(0xFF7A8FA8) +private val TextDim = Color(0xFF6E7681) +private val DividerColor = Color(0xFF2A2A3A) +private val NavHighlight = Color(0xFF4FC3F7) + +private val LabelSize = 11.sp +private val ValueSize = 12.sp +private val GroupCorner = 12.dp +private val GroupPadding = 12.dp +private val FieldCorner = 8.dp +private val ItemGap = 10.dp +private val TightGap = 4.dp + +private val SHADER_KEYS = listOf("default", "crt", "lcd", "sharp") +private val SHADER_LABELS = listOf("Default", "CRT", "LCD", "Sharp") + +class RetroSettingsState( + val shortcut: Shortcut, +) { + val system: RetroSystem? = RetroShortcuts.systemForShortcut(shortcut) + val coreOptions: List = RetroCoreOptions.forSystem(system) + val name: String = shortcut.getExtra("custom_name", shortcut.name) + val romPath: String = shortcut.getExtra(RetroShortcuts.KEY_ROM) + + var shader by mutableStateOf( + shortcut.getExtra(RetroShortcuts.KEY_SHADER, "default").lowercase().let { + if (it in SHADER_KEYS) it else "default" + }, + ) + var touchControls by mutableStateOf(shortcut.getExtra(RetroShortcuts.KEY_TOUCH_CONTROLS, "1") != "0") + var audio by mutableStateOf(shortcut.getExtra(RetroShortcuts.KEY_AUDIO, "1") != "0") + val optionValues = + mutableStateMapOf().apply { + coreOptions.forEach { option -> + put( + option.key, + shortcut.getExtra(RetroShortcuts.VAR_PREFIX + option.key).ifEmpty { option.defaultValue }, + ) + } + } + var currentSection by mutableIntStateOf(0) + + fun save() { + shortcut.putExtra(RetroShortcuts.KEY_SHADER, shader) + shortcut.putExtra(RetroShortcuts.KEY_TOUCH_CONTROLS, if (touchControls) "1" else "0") + shortcut.putExtra(RetroShortcuts.KEY_AUDIO, if (audio) "1" else "0") + coreOptions.forEach { option -> + shortcut.putExtra( + RetroShortcuts.VAR_PREFIX + option.key, + optionValues[option.key] ?: option.defaultValue, + ) + } + shortcut.saveData() + } +} + +private data class RetroSection( + val icon: ImageVector, + val label: String, +) + +private fun buildRetroSections(state: RetroSettingsState): List { + val sections = mutableListOf() + sections += RetroSection(Icons.Outlined.Tune, "General") + sections += RetroSection(Icons.Outlined.Monitor, "Graphics") + if (state.coreOptions.isNotEmpty()) { + sections += RetroSection(Icons.Outlined.Memory, "${state.system?.shortName ?: "Core"} Options") + } + sections += RetroSection(Icons.Outlined.SportsEsports, "Input") + sections += RetroSection(Icons.AutoMirrored.Outlined.VolumeUp, "Audio") + return sections +} + +@Composable +fun RetroGameSettingsContent( + state: RetroSettingsState, + nav: GameSettingsNav? = null, + onSave: () -> Unit, + onCancel: () -> Unit, +) { + val sections = remember(state) { buildRetroSections(state) } + val hasSystemSection = state.coreOptions.isNotEmpty() + val selectedIdx = state.currentSection + + if (nav != null) { + SideEffect { + nav.sidebarCount = sections.size + nav.onSelectSection = { state.currentSection = it } + nav.onSave = onSave + nav.onCancel = onCancel + } + } + + Box( + modifier = + Modifier + .fillMaxSize() + .clip(RoundedCornerShape(16.dp)) + .background(BgDeep), + ) { + Row(modifier = Modifier.fillMaxSize()) { + RetroSidebar( + title = state.name, + subtitle = state.system?.displayName ?: "", + sections = sections, + currentIndex = selectedIdx, + onSectionSelected = { state.currentSection = it }, + onSave = onSave, + onCancel = onCancel, + nav = nav, + modifier = Modifier.width(220.dp).fillMaxHeight(), + ) + Box(Modifier.width(1.dp).fillMaxHeight().background(DividerColor)) + Box( + modifier = + Modifier + .weight(1f) + .fillMaxHeight() + .background(ContentBg), + ) { + RetroSectionContent( + sectionIndex = selectedIdx, + hasSystemSection = hasSystemSection, + state = state, + nav = nav, + ) + } + } + } +} + +@Composable +private fun RetroSectionContent( + sectionIndex: Int, + hasSystemSection: Boolean, + state: RetroSettingsState, + nav: GameSettingsNav? = null, +) { + AnimatedContent( + targetState = sectionIndex, + transitionSpec = { + val direction = if (targetState > initialState) 1 else -1 + ( + slideInHorizontally(animationSpec = tween(220)) { direction * it / 6 } + + fadeIn(tween(200)) + ).togetherWith( + slideOutHorizontally(animationSpec = tween(180)) { -direction * it / 6 } + + fadeOut(tween(120)), + ) + }, + label = "RetroSectionTransition", + ) { idx -> + val contentNav = remember(nav) { nav?.let { PaneNavRegistry(initialSignal = it.contentSignal) } } + val isCurrent = idx == sectionIndex + if (nav != null && contentNav != null) { + contentNav.controllerActive = nav.active && nav.inContent && isCurrent + contentNav.onEdgeLeft = { nav.exitToSidebar() } + if (isCurrent) { + nav.onContentBack = { + if (contentNav.overlay != null) { + contentNav.overlayClose?.invoke() + true + } else { + false + } + } + } + LaunchedEffect(nav.contentSignal) { + if (isCurrent) contentNav.processNav(nav.contentSignal, nav.contentDir) + } + LaunchedEffect(nav.contentResetSignal) { + if (isCurrent) contentNav.reset() + } + } + val sectionBody: @Composable () -> Unit = { + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp, vertical = 14.dp), + ) { + val systemIdx = 2 + when { + idx == 0 -> RetroGeneralSection(state) + idx == 1 -> RetroGraphicsSection(state) + hasSystemSection && idx == systemIdx -> RetroSystemSection(state) + idx == (if (hasSystemSection) 3 else 2) -> RetroInputSection(state) + else -> RetroAudioSection(state) + } + Spacer(Modifier.height(12.dp)) + } + } + if (contentNav != null) { + CompositionLocalProvider(LocalPaneNav provides contentNav) { sectionBody() } + } else { + sectionBody() + } + } +} + +@Composable +private fun RetroSidebar( + title: String, + subtitle: String, + sections: List, + currentIndex: Int, + onSectionSelected: (Int) -> Unit, + onSave: () -> Unit, + onCancel: () -> Unit, + nav: GameSettingsNav? = null, + modifier: Modifier = Modifier, +) { + val cancelHighlighted = nav != null && nav.active && !nav.inContent && nav.onActionRow && nav.actionCol == 0 + val saveHighlighted = nav != null && nav.active && !nav.inContent && nav.onActionRow && nav.actionCol == 1 + Column( + modifier = + modifier + .background(SidebarBg) + .padding(top = 14.dp, bottom = 12.dp), + ) { + Text( + text = title, + color = TextPrimary, + fontSize = LabelSize, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.2.sp, + lineHeight = 15.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 2.dp), + ) + if (subtitle.isNotBlank()) { + Text( + text = subtitle, + color = TextSecondary, + fontSize = 10.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 10.dp), + ) + } + Box( + modifier = + Modifier + .padding(horizontal = 12.dp) + .fillMaxWidth() + .height(1.dp) + .background(DividerColor), + ) + Spacer(Modifier.height(8.dp)) + + Column( + modifier = + Modifier + .weight(1f) + .verticalScroll(rememberScrollState()) + .padding(bottom = 8.dp), + verticalArrangement = Arrangement.spacedBy(1.dp), + ) { + sections.forEachIndexed { index, section -> + RetroSidebarItem( + icon = section.icon, + label = section.label, + isSelected = currentIndex == index, + navHighlighted = + nav != null && nav.active && !nav.inContent && !nav.onActionRow && + nav.sidebarIndex == index, + onClick = { + if (nav != null) nav.tapSection(index) else onSectionSelected(index) + }, + ) + } + } + + Box( + modifier = + Modifier + .padding(horizontal = 12.dp) + .fillMaxWidth() + .height(1.dp) + .background(DividerColor), + ) + Spacer(Modifier.height(8.dp)) + + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box( + modifier = + Modifier + .weight(1f) + .height(30.dp) + .clip(RoundedCornerShape(8.dp)) + .border(1.dp, CardBorder, RoundedCornerShape(8.dp)) + .background(CardSurface) + .paneHighlight(cancelHighlighted, cornerRadius = 8.dp, highlightColor = NavHighlight) + .clickable { + nav?.tapAction(0) + onCancel() + }, + contentAlignment = Alignment.Center, + ) { + Text("Cancel", color = TextSecondary, fontSize = LabelSize, fontWeight = FontWeight.Medium) + } + Box( + modifier = + Modifier + .weight(1f) + .height(30.dp) + .clip(RoundedCornerShape(8.dp)) + .border(1.dp, AccentBlue.copy(alpha = 0.5f), RoundedCornerShape(8.dp)) + .background(AccentBlue.copy(alpha = 0.1f)) + .paneHighlight(saveHighlighted, cornerRadius = 8.dp, highlightColor = NavHighlight) + .clickable { + nav?.tapAction(1) + onSave() + }, + contentAlignment = Alignment.Center, + ) { + Text("Save", color = AccentBlue, fontSize = LabelSize, fontWeight = FontWeight.Medium) + } + } + } +} + +@Composable +private fun RetroSidebarItem( + icon: ImageVector, + label: String, + isSelected: Boolean, + navHighlighted: Boolean = false, + onClick: () -> Unit, +) { + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp) + .clip(RoundedCornerShape(8.dp)) + .chasingBorder(isFocused = isSelected, cornerRadius = 8.dp, borderWidth = 2.dp) + .paneHighlight(navHighlighted, cornerRadius = 8.dp, highlightColor = NavHighlight) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick, + ) + .padding(horizontal = 12.dp, vertical = 8.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + icon, + contentDescription = null, + tint = if (isSelected) AccentBlue else TextDim, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(10.dp)) + Text( + label, + color = if (isSelected) TextPrimary else TextSecondary, + fontSize = ValueSize, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, + maxLines = 1, + ) + } + } +} + +@Composable +private fun RetroSettingGroup(content: @Composable () -> Unit) { + Column( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(GroupCorner)) + .background(CardSurface) + .border(1.dp, CardBorder, RoundedCornerShape(GroupCorner)) + .padding(GroupPadding), + ) { + content() + } +} + +@Composable +private fun RetroGroupTitle(text: String) { + Text( + text = text, + color = TextSecondary, + fontSize = LabelSize, + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.4.sp, + modifier = Modifier.padding(bottom = ItemGap), + ) +} + +@Composable +private fun RetroInfoRow( + label: String, + value: String, +) { + Column(Modifier.fillMaxWidth().padding(bottom = ItemGap)) { + Text( + label, + color = TextSecondary, + fontSize = LabelSize, + fontWeight = FontWeight.Medium, + letterSpacing = 0.3.sp, + modifier = Modifier.padding(bottom = TightGap), + ) + Text( + value.ifBlank { "-" }, + color = TextPrimary, + fontSize = ValueSize, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun RetroSettingDropdown( + label: String, + entries: List, + selectedIndex: Int, + onSelected: (Int) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + val selectedText = entries.getOrElse(selectedIndex) { "" } + val parentNav = LocalPaneNav.current + val optionRegistry = remember { PaneNavRegistry() } + LaunchedEffect(expanded) { + if (expanded) { + optionRegistry.reset() + optionRegistry.controllerActive = true + parentNav?.overlay = optionRegistry + parentNav?.overlayClose = { expanded = false } + } else if (parentNav?.overlay === optionRegistry) { + parentNav.overlay = null + parentNav.overlayClose = null + } + } + + Column(modifier = Modifier.fillMaxWidth().padding(bottom = ItemGap)) { + Text( + label, + color = TextSecondary, + fontSize = LabelSize, + fontWeight = FontWeight.Medium, + letterSpacing = 0.3.sp, + modifier = Modifier.padding(bottom = TightGap), + ) + Box { + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(FieldCorner)) + .background(InputSurface) + .border(1.dp, InputBorder, RoundedCornerShape(FieldCorner)) + .paneNavItem( + cornerRadius = FieldCorner, + onActivate = { expanded = true }, + highlightColor = NavHighlight, + ) + .clickable { expanded = true } + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + selectedText, + color = TextPrimary, + fontSize = ValueSize, + modifier = Modifier.weight(1f), + maxLines = 1, + ) + Icon( + Icons.Outlined.KeyboardArrowDown, + contentDescription = null, + tint = TextDim, + modifier = Modifier.size(18.dp), + ) + } + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + shape = RoundedCornerShape(8.dp), + containerColor = CardSurface, + properties = PopupProperties(focusable = false), + ) { + CompositionLocalProvider(LocalPaneNav provides optionRegistry) { + entries.forEachIndexed { index, entry -> + DropdownMenuItem( + text = { + Text( + entry, + color = if (index == selectedIndex) AccentBlue else TextPrimary, + fontSize = ValueSize, + fontWeight = if (index == selectedIndex) FontWeight.Medium else FontWeight.Normal, + ) + }, + onClick = { + onSelected(index) + expanded = false + }, + modifier = + ( + if (index == selectedIndex) { + Modifier.background(AccentBlue.copy(alpha = 0.06f)) + } else { + Modifier + } + ).paneNavItem( + cornerRadius = 6.dp, + onActivate = { + onSelected(index) + expanded = false + }, + isEntry = index == selectedIndex, + highlightColor = NavHighlight, + ), + ) + } + } + } + } + } +} + +@Composable +private fun RetroSettingSwitch( + label: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { onCheckedChange(!checked) } + .paneNavItem( + cornerRadius = 8.dp, + onActivate = { onCheckedChange(!checked) }, + highlightColor = NavHighlight, + tapToSelect = true, + ) + .padding(vertical = TightGap), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + label, + color = TextPrimary, + fontSize = ValueSize, + modifier = Modifier.weight(1f), + ) + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + colors = outlinedSwitchColors(accentColor = AccentBlue, textSecondaryColor = TextSecondary), + ) + } +} + +@Composable +private fun RetroGeneralSection(state: RetroSettingsState) { + RetroSettingGroup { + RetroGroupTitle("GAME") + RetroInfoRow("Name", state.name) + RetroInfoRow("System", state.system?.displayName ?: "") + RetroInfoRow("Emulator Core", state.system?.coreFileName ?: "") + RetroInfoRow("ROM Path", state.romPath) + } +} + +@Composable +private fun RetroGraphicsSection(state: RetroSettingsState) { + RetroSettingGroup { + RetroGroupTitle("VIDEO") + RetroSettingDropdown( + label = "Video Filter", + entries = SHADER_LABELS, + selectedIndex = SHADER_KEYS.indexOf(state.shader).coerceAtLeast(0), + onSelected = { state.shader = SHADER_KEYS[it] }, + ) + } +} + +@Composable +private fun RetroSystemSection(state: RetroSettingsState) { + RetroSettingGroup { + RetroGroupTitle((state.system?.shortName ?: "CORE").uppercase() + " OPTIONS") + state.coreOptions.forEach { option -> + val current = state.optionValues[option.key] ?: option.defaultValue + RetroSettingDropdown( + label = option.label, + entries = option.valueLabels, + selectedIndex = option.values.indexOf(current).coerceAtLeast(0), + onSelected = { state.optionValues[option.key] = option.values[it] }, + ) + } + } +} + +@Composable +private fun RetroInputSection(state: RetroSettingsState) { + RetroSettingGroup { + RetroGroupTitle("INPUT") + RetroSettingSwitch( + label = "On-screen controls", + checked = state.touchControls, + onCheckedChange = { state.touchControls = it }, + ) + } +} + +@Composable +private fun RetroAudioSection(state: RetroSettingsState) { + RetroSettingGroup { + RetroGroupTitle("AUDIO") + RetroSettingSwitch( + label = "Sound", + checked = state.audio, + onCheckedChange = { state.audio = it }, + ) + } +} diff --git a/app/src/main/feature/retro/RetroGameSettingsDialog.kt b/app/src/main/feature/retro/RetroGameSettingsDialog.kt deleted file mode 100644 index 389d46f91..000000000 --- a/app/src/main/feature/retro/RetroGameSettingsDialog.kt +++ /dev/null @@ -1,190 +0,0 @@ -package com.winlator.cmod.feature.retro - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.selection.selectable -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.RadioButton -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import com.winlator.cmod.runtime.container.Shortcut - -private val SHADERS = listOf("default" to "Default", "crt" to "CRT", "lcd" to "LCD", "sharp" to "Sharp") - -@Composable -fun RetroSettingsContent( - shortcut: Shortcut, - onDone: () -> Unit, -) { - val system = RetroShortcuts.systemForShortcut(shortcut) - val coreOptions = remember(system?.id) { RetroCoreOptions.forSystem(system) } - var shader by remember { mutableStateOf(shortcut.getExtra(RetroShortcuts.KEY_SHADER, "default")) } - var touchControls by remember { mutableStateOf(shortcut.getExtra(RetroShortcuts.KEY_TOUCH_CONTROLS, "1") != "0") } - var audio by remember { mutableStateOf(shortcut.getExtra(RetroShortcuts.KEY_AUDIO, "1") != "0") } - val optionValues = - remember(system?.id) { - mutableStateMapOf().apply { - coreOptions.forEach { option -> - put( - option.key, - shortcut - .getExtra(RetroShortcuts.VAR_PREFIX + option.key) - .ifEmpty { option.defaultValue }, - ) - } - } - } - - fun persist() { - shortcut.putExtra(RetroShortcuts.KEY_SHADER, shader) - shortcut.putExtra(RetroShortcuts.KEY_TOUCH_CONTROLS, if (touchControls) "1" else "0") - shortcut.putExtra(RetroShortcuts.KEY_AUDIO, if (audio) "1" else "0") - coreOptions.forEach { option -> - val value = optionValues[option.key] ?: option.defaultValue - shortcut.putExtra(RetroShortcuts.VAR_PREFIX + option.key, value) - } - shortcut.saveData() - } - - Column( - Modifier - .padding(20.dp) - .heightIn(max = 560.dp), - ) { - Text( - text = system?.displayName ?: "Console Game", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - Text( - text = "Emulator settings", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 12.dp), - ) - - Column( - Modifier - .weight(1f, fill = false) - .verticalScroll(rememberScrollState()), - ) { - Text( - text = "Video filter", - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurface, - ) - SHADERS.forEach { (key, label) -> - Row( - modifier = - Modifier - .fillMaxWidth() - .selectable(selected = shader == key, onClick = { shader = key }) - .padding(vertical = 2.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - RadioButton(selected = shader == key, onClick = { shader = key }) - Text(label, color = MaterialTheme.colorScheme.onSurface) - } - } - - if (coreOptions.isNotEmpty()) { - Text( - text = "${system?.shortName ?: "Core"} options", - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(top = 12.dp, bottom = 4.dp), - ) - coreOptions.forEach { option -> - val current = optionValues[option.key] ?: option.defaultValue - val currentIndex = option.values.indexOf(current).coerceAtLeast(0) - Row( - modifier = - Modifier - .fillMaxWidth() - .clickable { - optionValues[option.key] = - option.values[(currentIndex + 1) % option.values.size] - }.padding(vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text(option.label, color = MaterialTheme.colorScheme.onSurface) - Text( - option.valueLabels[currentIndex], - color = MaterialTheme.colorScheme.primary, - ) - } - } - } - - Text( - text = "General", - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.padding(top = 12.dp, bottom = 4.dp), - ) - ToggleRow("On-screen controls", touchControls) { touchControls = it } - ToggleRow("Sound", audio) { audio = it } - } - - Row( - modifier = Modifier.fillMaxWidth().padding(top = 8.dp), - horizontalArrangement = Arrangement.End, - ) { - TextButton(onClick = { persist(); onDone() }) { Text("Done") } - } - } -} - -@Composable -fun RetroGameSettingsDialog( - shortcut: Shortcut, - onDismiss: () -> Unit, -) { - Dialog(onDismissRequest = onDismiss) { - androidx.compose.material3.Surface( - shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surface, - ) { - RetroSettingsContent(shortcut = shortcut, onDone = onDismiss) - } - } -} - -@Composable -private fun ToggleRow( - label: String, - checked: Boolean, - onChange: (Boolean) -> Unit, -) { - Row( - modifier = - Modifier - .fillMaxWidth() - .clickable { onChange(!checked) } - .padding(vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text(label, color = MaterialTheme.colorScheme.onSurface) - Switch(checked = checked, onCheckedChange = onChange) - } -} diff --git a/app/src/main/feature/retro/RetroSettingsDialog.kt b/app/src/main/feature/retro/RetroSettingsDialog.kt index bfb044b21..89b3043ad 100644 --- a/app/src/main/feature/retro/RetroSettingsDialog.kt +++ b/app/src/main/feature/retro/RetroSettingsDialog.kt @@ -2,34 +2,40 @@ package com.winlator.cmod.feature.retro import android.app.Activity import android.app.Dialog +import android.os.Build import android.view.ViewGroup import android.view.Window import android.view.WindowManager -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.widthIn -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.unit.dp +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.setViewTreeLifecycleOwner import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner +import com.winlator.cmod.R +import com.winlator.cmod.feature.library.GameSettingsNav import com.winlator.cmod.runtime.container.Shortcut import com.winlator.cmod.shared.theme.WinNativeTheme +import com.winlator.cmod.shared.ui.nav.PANE_DIR_ACTIVATE +import com.winlator.cmod.shared.ui.nav.PaneNavWindowHandlers +import com.winlator.cmod.shared.ui.nav.bindPaneNav class RetroSettingsDialog( private val activity: Activity, - private val shortcut: Shortcut, + shortcut: Shortcut, ) { + private val state = RetroSettingsState(shortcut) + private val nav = GameSettingsNav() + private var restorePaneNav: (() -> Unit)? = null + private val dialog: Dialog = - Dialog(activity).apply { + Dialog(activity, R.style.ContentDialog).apply { requestWindowFeature(Window.FEATURE_NO_TITLE) setCancelable(true) + setCanceledOnTouchOutside(false) setOwnerActivity(activity) window?.apply { setBackgroundDrawableResource(android.R.color.transparent) @@ -39,6 +45,14 @@ class RetroSettingsDialog( ) setDimAmount(0.5f) addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) + setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + isNavigationBarContrastEnforced = false + } + } + setOnDismissListener { + restorePaneNav?.invoke() + restorePaneNav = null } } @@ -54,20 +68,19 @@ class RetroSettingsDialog( setViewTreeSavedStateRegistryOwner(activity as SavedStateRegistryOwner) setContent { WinNativeTheme { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, + val defaultDensity = LocalDensity.current + CompositionLocalProvider( + LocalDensity provides Density(defaultDensity.density, fontScale = 1f), ) { - Surface( - shape = MaterialTheme.shapes.large, - color = MaterialTheme.colorScheme.surface, - modifier = Modifier.widthIn(max = 420.dp), - ) { - RetroSettingsContent( - shortcut = shortcut, - onDone = { dialog.dismiss() }, - ) - } + RetroGameSettingsContent( + state = state, + nav = nav, + onSave = { + state.save() + dialog.dismiss() + }, + onCancel = { dialog.dismiss() }, + ) } } } @@ -84,5 +97,37 @@ class RetroSettingsDialog( fun show() { dialog.show() + restorePaneNav?.invoke() + restorePaneNav = + dialog.window?.bindPaneNav( + PaneNavWindowHandlers( + onDir = { nav.dpad(it) }, + onActivate = { nav.dpad(PANE_DIR_ACTIVATE) }, + onDismiss = { if (nav.onContentBack?.invoke() != true) dialog.dismiss() }, + onStart = { nav.onSave?.invoke() }, + ), + ) + dialog.window?.apply { + applyDialogLayout() + decorView.post { applyDialogLayout() } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val params = attributes + params.flags = params.flags or WindowManager.LayoutParams.FLAG_BLUR_BEHIND + params.blurBehindRadius = 10 + attributes = params + } + } + } + + private fun Window.applyDialogLayout() { + val dm = activity.resources.displayMetrics + val hostView = activity.window?.decorView + val hostWidth = hostView?.width?.takeIf { it > 0 } ?: dm.widthPixels + val hostHeight = hostView?.height?.takeIf { it > 0 } ?: dm.heightPixels + val screenWidthDp = hostWidth / dm.density + val needsNearFullWidth = screenWidthDp < 820f + val widthFactor = if (needsNearFullWidth) 0.96f else 0.88f + val heightFactor = if (needsNearFullWidth) 0.90f else 0.88f + setLayout((hostWidth * widthFactor).toInt(), (hostHeight * heightFactor).toInt()) } } From 566b8226788ba25cd31270fc7d8b550b7f14143f Mon Sep 17 00:00:00 2001 From: max Date: Mon, 13 Jul 2026 11:59:23 -0400 Subject: [PATCH 04/38] Style retro on-screen controls in the GameHub glass look by default - RetroInputView rebuilt with the exact GameHub/Glass paint recipe from the PC overlay renderer: dark translucent glass fill, light white rim, brighter rim and inner fill when pressed, radial glass edge vignette, bold labels - D-pad drawn as the GameHub four-arrow cross via the shared GameHubLayout path builders, with per-direction pressed highlighting - Shoulder buttons and triggers drawn as GameHub slanted glass pills (TRIGGER_LB/RB/LT/RT silhouettes); Start/Select/Menu as glass pills; face buttons as glass circles - Overlay is gated per console: 2-button pads for NES/GB/GBC/SMS/GG, X/Y and shoulders for SNES/Genesis, shoulders for GBA, triggers for PS1, and an analog glass stick plus Z trigger for N64 wired to the libretro left analog --- app/src/main/feature/retro/RetroActivity.kt | 10 +- app/src/main/feature/retro/RetroInputView.kt | 489 ++++++++++++++----- 2 files changed, 371 insertions(+), 128 deletions(-) diff --git a/app/src/main/feature/retro/RetroActivity.kt b/app/src/main/feature/retro/RetroActivity.kt index 919af845f..a3dd2a472 100644 --- a/app/src/main/feature/retro/RetroActivity.kt +++ b/app/src/main/feature/retro/RetroActivity.kt @@ -132,7 +132,7 @@ class RetroActivity : AppCompatActivity(), RetroInputView.Listener { ), ) - val inputView = RetroInputView(this, this) + val inputView = RetroInputView(this, this, resolvedSystem) inputView.visibility = if (touchControlsSetting) View.VISIBLE else View.GONE overlay = inputView root.addView( @@ -437,6 +437,14 @@ class RetroActivity : AppCompatActivity(), RetroInputView.Listener { retroView.sendMotionEvent(GLRetroView.MOTION_SOURCE_DPAD, x, y, 0) } + override fun onStick( + x: Float, + y: Float, + ) { + if (!retroReady || menu.visible) return + retroView.sendMotionEvent(GLRetroView.MOTION_SOURCE_ANALOG_LEFT, x, y, 0) + } + override fun onMenu() { runOnUiThread { openMenu() } } diff --git a/app/src/main/feature/retro/RetroInputView.kt b/app/src/main/feature/retro/RetroInputView.kt index 4fd519105..529c64b77 100644 --- a/app/src/main/feature/retro/RetroInputView.kt +++ b/app/src/main/feature/retro/RetroInputView.kt @@ -4,15 +4,22 @@ import android.content.Context import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint +import android.graphics.Path +import android.graphics.RadialGradient import android.graphics.RectF +import android.graphics.Shader import android.view.KeyEvent import android.view.MotionEvent import android.view.View -import kotlin.math.abs +import com.winlator.cmod.runtime.input.controls.GameHubLayout +import kotlin.math.hypot +import kotlin.math.max +import kotlin.math.min class RetroInputView( context: Context, private val listener: Listener, + private val system: RetroSystem? = null, ) : View(context) { interface Listener { fun onButton( @@ -25,81 +32,87 @@ class RetroInputView( y: Float, ) + fun onStick( + x: Float, + y: Float, + ) + fun onMenu() } - private data class Button( + private enum class GlassShape { CIRCLE, PILL, TRIGGER_LT, TRIGGER_LB, TRIGGER_RT, TRIGGER_RB } + + private class GlassButton( val keyCode: Int, val label: String, - var cx: Float = 0f, - var cy: Float = 0f, - var radius: Float = 0f, + val shape: GlassShape, + val bounds: RectF = RectF(), ) - private val faceButtons = - listOf( - Button(KeyEvent.KEYCODE_BUTTON_X, "X"), - Button(KeyEvent.KEYCODE_BUTTON_B, "B"), - Button(KeyEvent.KEYCODE_BUTTON_Y, "Y"), - Button(KeyEvent.KEYCODE_BUTTON_A, "A"), - ) - private val shoulderL = Button(KeyEvent.KEYCODE_BUTTON_L1, "L") - private val shoulderR = Button(KeyEvent.KEYCODE_BUTTON_R1, "R") - private val selectButton = Button(KeyEvent.KEYCODE_BUTTON_SELECT, "SELECT") - private val startButton = Button(KeyEvent.KEYCODE_BUTTON_START, "START") - private val menuButton = Button(0, "MENU") + private data class OverlayConfig( + val hasXY: Boolean, + val hasShoulders: Boolean, + val hasTriggers: Boolean, + val hasStick: Boolean, + val leftTriggerLabel: String = "L2", + val rightTriggerLabel: String = "R2", + val showRightTrigger: Boolean = true, + ) + + private val config = + when (system?.id) { + RetroSystems.SNES.id -> OverlayConfig(hasXY = true, hasShoulders = true, hasTriggers = false, hasStick = false) + RetroSystems.GBA.id -> OverlayConfig(hasXY = false, hasShoulders = true, hasTriggers = false, hasStick = false) + RetroSystems.GENESIS.id -> OverlayConfig(hasXY = true, hasShoulders = true, hasTriggers = false, hasStick = false) + RetroSystems.N64.id -> + OverlayConfig( + hasXY = false, + hasShoulders = true, + hasTriggers = true, + hasStick = true, + leftTriggerLabel = "Z", + showRightTrigger = false, + ) + RetroSystems.PSX.id -> OverlayConfig(hasXY = true, hasShoulders = true, hasTriggers = true, hasStick = false) + else -> OverlayConfig(hasXY = false, hasShoulders = false, hasTriggers = false, hasStick = false) + } - private val allButtons - get() = faceButtons + shoulderL + shoulderR + selectButton + startButton + menuButton + private val buttons = mutableListOf() + private val menuButton = GlassButton(0, "MENU", GlassShape.PILL) private var dpadCx = 0f private var dpadCy = 0f private var dpadRadius = 0f + private var stickCx = 0f + private var stickCy = 0f + private var stickRadius = 0f + private var stickPointerId = -1 + private var stickX = 0f + private var stickY = 0f + private val pressedButtons = HashSet() private var dpadX = 0f private var dpadY = 0f private var menuLatched = false - private val basePaint = - Paint(Paint.ANTI_ALIAS_FLAG).apply { - style = Paint.Style.FILL - color = Color.argb(90, 255, 255, 255) - } - private val pressedPaint = - Paint(Paint.ANTI_ALIAS_FLAG).apply { - style = Paint.Style.FILL - color = Color.argb(170, 90, 170, 255) - } - private val strokePaint = - Paint(Paint.ANTI_ALIAS_FLAG).apply { - style = Paint.Style.STROKE - strokeWidth = 3f - color = Color.argb(150, 230, 230, 230) - } - private val textPaint = - Paint(Paint.ANTI_ALIAS_FLAG).apply { - color = Color.argb(230, 20, 24, 30) - textAlign = Paint.Align.CENTER - } + private var strokeWidth = 4f + private val paint = Paint(Paint.ANTI_ALIAS_FLAG) + private val path = Path() + private val arrowCenter = FloatArray(2) + + private val fillColor = Color.argb(90, 0, 0, 0) + private val strokeColor = Color.argb(150, 255, 255, 255) + private val pressedFillColor = Color.argb(60, 255, 255, 255) + private val pressedStrokeColor = Color.argb(220, 255, 255, 255) + private val textColor = Color.argb(255, 255, 255, 255) + private val glassEdgeAlpha = 75 init { isFocusable = false isFocusableInTouchMode = false } - fun releaseAll() { - for (keyCode in pressedButtons) listener.onButton(keyCode, false) - pressedButtons.clear() - if (dpadX != 0f || dpadY != 0f) { - dpadX = 0f - dpadY = 0f - listener.onDpad(0f, 0f) - } - menuLatched = false - invalidate() - } - override fun onSizeChanged( w: Int, h: Int, @@ -107,81 +120,257 @@ class RetroInputView( oldh: Int, ) { super.onSizeChanged(w, h, oldw, oldh) + buttons.clear() val width = w.toFloat() val height = h.toFloat() - val unit = minOf(width, height) + val unit = min(width, height) + val margin = unit * 0.05f val faceRadius = unit * 0.085f - val margin = unit * 0.06f - - dpadRadius = unit * 0.16f - dpadCx = margin + dpadRadius - dpadCy = height - margin - dpadRadius - - val clusterCx = width - margin - dpadRadius - val clusterCy = height - margin - dpadRadius - val spread = faceRadius * 1.7f - faceButtons.forEach { it.radius = faceRadius } - faceButtons[0].cx = clusterCx - faceButtons[0].cy = clusterCy - spread - faceButtons[1].cx = clusterCx - faceButtons[1].cy = clusterCy + spread - faceButtons[2].cx = clusterCx - spread - faceButtons[2].cy = clusterCy - faceButtons[3].cx = clusterCx + spread - faceButtons[3].cy = clusterCy - - shoulderL.radius = faceRadius - shoulderL.cx = margin + faceRadius - shoulderL.cy = margin + faceRadius - shoulderR.radius = faceRadius - shoulderR.cx = width - margin - faceRadius - shoulderR.cy = margin + faceRadius - - val smallRadius = faceRadius * 0.85f - selectButton.radius = smallRadius - selectButton.cx = width * 0.5f - smallRadius * 1.5f - selectButton.cy = height - margin - smallRadius - startButton.radius = smallRadius - startButton.cx = width * 0.5f + smallRadius * 1.5f - startButton.cy = height - margin - smallRadius - - menuButton.radius = smallRadius - menuButton.cx = width * 0.5f - menuButton.cy = margin + smallRadius + strokeWidth = max(2f, faceRadius * 0.16f) + + val trigW = unit * 0.30f + val trigH = unit * 0.105f + val trigGap = unit * 0.02f + + var leftShoulderTop = margin + var rightShoulderTop = margin + if (config.hasTriggers) { + val lt = GlassButton(KeyEvent.KEYCODE_BUTTON_L2, config.leftTriggerLabel, GlassShape.TRIGGER_LT) + lt.bounds.set(margin, leftShoulderTop, margin + trigW, leftShoulderTop + trigH) + buttons += lt + leftShoulderTop += trigH + trigGap + if (config.showRightTrigger) { + val rt = GlassButton(KeyEvent.KEYCODE_BUTTON_R2, config.rightTriggerLabel, GlassShape.TRIGGER_RT) + rt.bounds.set(width - margin - trigW, rightShoulderTop, width - margin, rightShoulderTop + trigH) + buttons += rt + rightShoulderTop += trigH + trigGap + } + } + if (config.hasShoulders) { + val lb = GlassButton(KeyEvent.KEYCODE_BUTTON_L1, "L", GlassShape.TRIGGER_LB) + lb.bounds.set(margin, leftShoulderTop, margin + trigW, leftShoulderTop + trigH) + buttons += lb + val rb = GlassButton(KeyEvent.KEYCODE_BUTTON_R1, "R", GlassShape.TRIGGER_RB) + rb.bounds.set(width - margin - trigW, rightShoulderTop, width - margin, rightShoulderTop + trigH) + buttons += rb + } + + val clusterCx = width - margin - faceRadius * 2.6f + val clusterCy = height - margin - faceRadius * 2.6f + val spread = faceRadius * 1.75f + fun addFace( + keyCode: Int, + label: String, + cx: Float, + cy: Float, + ) { + val button = GlassButton(keyCode, label, GlassShape.CIRCLE) + button.bounds.set(cx - faceRadius, cy - faceRadius, cx + faceRadius, cy + faceRadius) + buttons += button + } + if (config.hasXY) { + addFace(KeyEvent.KEYCODE_BUTTON_X, "X", clusterCx, clusterCy - spread) + addFace(KeyEvent.KEYCODE_BUTTON_B, "B", clusterCx, clusterCy + spread) + addFace(KeyEvent.KEYCODE_BUTTON_Y, "Y", clusterCx - spread, clusterCy) + addFace(KeyEvent.KEYCODE_BUTTON_A, "A", clusterCx + spread, clusterCy) + } else { + addFace(KeyEvent.KEYCODE_BUTTON_B, "B", clusterCx - faceRadius * 1.1f, clusterCy + faceRadius * 0.7f) + addFace(KeyEvent.KEYCODE_BUTTON_A, "A", clusterCx + faceRadius * 1.1f, clusterCy - faceRadius * 0.7f) + } + + dpadRadius = unit * 0.155f + if (config.hasStick) { + stickRadius = unit * 0.13f + stickCx = margin + stickRadius * 1.4f + stickCy = height - margin - stickRadius + dpadCx = margin + dpadRadius * 0.9f + dpadCy = stickCy - stickRadius - dpadRadius - unit * 0.04f + } else { + stickRadius = 0f + dpadCx = margin + dpadRadius * 1.15f + dpadCy = height - margin - dpadRadius * 1.15f + } + + val pillW = unit * 0.17f + val pillH = unit * 0.062f + val pillGap = unit * 0.02f + val pillY = height - margin - pillH + val select = GlassButton(KeyEvent.KEYCODE_BUTTON_SELECT, "SELECT", GlassShape.PILL) + select.bounds.set(width * 0.5f - pillGap * 0.5f - pillW, pillY, width * 0.5f - pillGap * 0.5f, pillY + pillH) + buttons += select + val start = GlassButton(KeyEvent.KEYCODE_BUTTON_START, "START", GlassShape.PILL) + start.bounds.set(width * 0.5f + pillGap * 0.5f, pillY, width * 0.5f + pillGap * 0.5f + pillW, pillY + pillH) + buttons += start + + menuButton.bounds.set(width * 0.5f - pillW * 0.5f, margin, width * 0.5f + pillW * 0.5f, margin + pillH) } override fun onDraw(canvas: Canvas) { super.onDraw(canvas) + paint.strokeWidth = strokeWidth + paint.strokeJoin = Paint.Join.ROUND + paint.strokeCap = Paint.Cap.ROUND drawDpad(canvas) - allButtons.forEach { drawButton(canvas, it) } + if (config.hasStick) drawStick(canvas) + buttons.forEach { drawGlassButton(canvas, it, pressedButtons.contains(it.keyCode)) } + drawGlassButton(canvas, menuButton, menuLatched) } - private fun drawDpad(canvas: Canvas) { - val arm = dpadRadius - val thickness = dpadRadius * 0.66f - val horizontal = - RectF(dpadCx - arm, dpadCy - thickness / 2, dpadCx + arm, dpadCy + thickness / 2) - val vertical = - RectF(dpadCx - thickness / 2, dpadCy - arm, dpadCx + thickness / 2, dpadCy + arm) - val corner = thickness * 0.35f - val activeX = abs(dpadX) > 0.1f - val activeY = abs(dpadY) > 0.1f - canvas.drawRoundRect(horizontal, corner, corner, if (activeX) pressedPaint else basePaint) - canvas.drawRoundRect(vertical, corner, corner, if (activeY) pressedPaint else basePaint) - canvas.drawRoundRect(horizontal, corner, corner, strokePaint) - canvas.drawRoundRect(vertical, corner, corner, strokePaint) + private fun buildShapePath(button: GlassButton) { + val b = button.bounds + when (button.shape) { + GlassShape.CIRCLE -> { + path.reset() + path.addCircle(b.centerX(), b.centerY(), b.width() * 0.5f, Path.Direction.CW) + } + GlassShape.PILL -> { + path.reset() + val r = b.height() * 0.5f + path.addRoundRect(b.left, b.top, b.right, b.bottom, r, r, Path.Direction.CW) + } + GlassShape.TRIGGER_LT -> + GameHubLayout.buildTriggerPath(path, GameHubLayout.RenderShape.TRIGGER_LT, b.left, b.top, b.right, b.bottom) + GlassShape.TRIGGER_LB -> + GameHubLayout.buildTriggerPath(path, GameHubLayout.RenderShape.TRIGGER_LB, b.left, b.top, b.right, b.bottom) + GlassShape.TRIGGER_RT -> + GameHubLayout.buildTriggerPath(path, GameHubLayout.RenderShape.TRIGGER_RT, b.left, b.top, b.right, b.bottom) + GlassShape.TRIGGER_RB -> + GameHubLayout.buildTriggerPath(path, GameHubLayout.RenderShape.TRIGGER_RB, b.left, b.top, b.right, b.bottom) + } } - private fun drawButton( + private fun drawGlassButton( canvas: Canvas, - button: Button, + button: GlassButton, + pressed: Boolean, ) { - val pressed = if (button.keyCode == 0) menuLatched else pressedButtons.contains(button.keyCode) - canvas.drawCircle(button.cx, button.cy, button.radius, if (pressed) pressedPaint else basePaint) - canvas.drawCircle(button.cx, button.cy, button.radius, strokePaint) - textPaint.textSize = button.radius * if (button.label.length > 2) 0.55f else 0.9f - val textY = button.cy - (textPaint.descent() + textPaint.ascent()) / 2 - canvas.drawText(button.label, button.cx, textY, textPaint) + val b = button.bounds + buildShapePath(button) + + paint.shader = null + paint.style = Paint.Style.FILL + paint.color = fillColor + canvas.drawPath(path, paint) + if (pressed) { + paint.color = pressedFillColor + canvas.drawPath(path, paint) + } + + paint.shader = + RadialGradient( + b.centerX(), + b.centerY(), + max(b.width(), b.height()) * 0.5f, + Color.argb(0, 0, 0, 0), + Color.argb(glassEdgeAlpha, 0, 0, 0), + Shader.TileMode.CLAMP, + ) + canvas.drawPath(path, paint) + paint.shader = null + + paint.style = Paint.Style.STROKE + paint.color = if (pressed) pressedStrokeColor else strokeColor + canvas.drawPath(path, paint) + + paint.style = Paint.Style.FILL + paint.color = textColor + paint.textAlign = Paint.Align.CENTER + paint.isFakeBoldText = true + val maxTextWidth = b.width() - strokeWidth * 3 + paint.textSize = b.height() * if (button.label.length > 2) 0.42f else 0.62f + if (button.label.isNotEmpty() && paint.measureText(button.label) > maxTextWidth) { + paint.textSize = paint.textSize * maxTextWidth / paint.measureText(button.label) + } + val textY = b.centerY() - (paint.descent() + paint.ascent()) * 0.5f + canvas.drawText(button.label, b.centerX(), textY, paint) + paint.isFakeBoldText = false + } + + private fun drawDpad(canvas: Canvas) { + val sidePressed = + booleanArrayOf(dpadY < -0.1f, dpadY > 0.1f, dpadX < -0.1f, dpadX > 0.1f) + for (side in 0 until 4) { + path.reset() + GameHubLayout.buildDpadArrow(path, side, dpadCx, dpadCy, dpadRadius) + paint.shader = null + paint.style = Paint.Style.FILL + paint.color = fillColor + canvas.drawPath(path, paint) + if (sidePressed[side]) { + paint.color = pressedFillColor + canvas.drawPath(path, paint) + } + GameHubLayout.dpadArrowCenter(side, dpadCx, dpadCy, dpadRadius, arrowCenter) + paint.shader = + RadialGradient( + arrowCenter[0], + arrowCenter[1], + dpadRadius * 0.5f, + Color.argb(0, 0, 0, 0), + Color.argb(glassEdgeAlpha, 0, 0, 0), + Shader.TileMode.CLAMP, + ) + paint.style = Paint.Style.FILL + canvas.drawPath(path, paint) + paint.shader = null + } + val engaged = dpadX != 0f || dpadY != 0f + GameHubLayout.buildDpadArrows(path, dpadCx, dpadCy, dpadRadius) + paint.style = Paint.Style.STROKE + paint.color = if (engaged) pressedStrokeColor else strokeColor + canvas.drawPath(path, paint) + } + + private fun drawStick(canvas: Canvas) { + val engaged = stickPointerId != -1 + paint.shader = null + paint.style = Paint.Style.FILL + paint.color = fillColor + canvas.drawCircle(stickCx, stickCy, stickRadius, paint) + + paint.shader = + RadialGradient( + stickCx, + stickCy, + stickRadius, + Color.argb(0, 0, 0, 0), + Color.argb(glassEdgeAlpha, 0, 0, 0), + Shader.TileMode.CLAMP, + ) + canvas.drawCircle(stickCx, stickCy, stickRadius, paint) + paint.shader = null + + paint.style = Paint.Style.STROKE + paint.color = if (engaged) pressedStrokeColor else strokeColor + canvas.drawCircle(stickCx, stickCy, stickRadius - strokeWidth * 0.5f, paint) + + val thumbX = stickCx + stickX * stickRadius * 0.52f + val thumbY = stickCy + stickY * stickRadius * 0.52f + val thumbRadius = stickRadius * 0.48f + paint.style = Paint.Style.FILL + paint.color = Color.argb(if (engaged) 100 else 77, 255, 255, 255) + canvas.drawCircle(thumbX, thumbY, thumbRadius, paint) + paint.style = Paint.Style.STROKE + paint.color = if (engaged) pressedStrokeColor else strokeColor + canvas.drawCircle(thumbX, thumbY, thumbRadius - strokeWidth * 0.5f, paint) + } + + fun releaseAll() { + for (keyCode in pressedButtons) listener.onButton(keyCode, false) + pressedButtons.clear() + if (dpadX != 0f || dpadY != 0f) { + dpadX = 0f + dpadY = 0f + listener.onDpad(0f, 0f) + } + if (stickPointerId != -1 || stickX != 0f || stickY != 0f) { + stickPointerId = -1 + stickX = 0f + stickY = 0f + listener.onStick(0f, 0f) + } + menuLatched = false + invalidate() } override fun onTouchEvent(event: MotionEvent): Boolean { @@ -199,8 +388,24 @@ class RetroInputView( return true } + private fun hitButton( + button: GlassButton, + x: Float, + y: Float, + ): Boolean { + val b = button.bounds + return if (button.shape == GlassShape.CIRCLE) { + val r = b.width() * 0.5f * 1.25f + hypot(x - b.centerX(), y - b.centerY()) <= r + } else { + x >= b.left - b.height() * 0.2f && x <= b.right + b.height() * 0.2f && + y >= b.top - b.height() * 0.25f && y <= b.bottom + b.height() * 0.25f + } + } + private fun recompute(event: MotionEvent) { - val released = event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL + val released = + event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL val liftedPointer = if (event.actionMasked == MotionEvent.ACTION_POINTER_UP) event.actionIndex else -1 @@ -208,39 +413,69 @@ class RetroInputView( var newDpadX = 0f var newDpadY = 0f var menuTouched = false + var stickSeen = false + var newStickX = stickX + var newStickY = stickY if (!released) { for (i in 0 until event.pointerCount) { if (i == liftedPointer) continue val x = event.getX(i) val y = event.getY(i) + val pointerId = event.getPointerId(i) + + if (config.hasStick) { + if (pointerId == stickPointerId) { + stickSeen = true + newStickX = ((x - stickCx) / stickRadius).coerceIn(-1f, 1f) + newStickY = ((y - stickCy) / stickRadius).coerceIn(-1f, 1f) + continue + } + if (stickPointerId == -1 && + hypot(x - stickCx, y - stickCy) <= stickRadius * 1.3f + ) { + stickPointerId = pointerId + stickSeen = true + newStickX = ((x - stickCx) / stickRadius).coerceIn(-1f, 1f) + newStickY = ((y - stickCy) / stickRadius).coerceIn(-1f, 1f) + continue + } + } val dxToPad = x - dpadCx val dyToPad = y - dpadCy - val padReach = dpadRadius * 1.5f - if (dxToPad * dxToPad + dyToPad * dyToPad <= padReach * padReach) { - val dz = dpadRadius * 0.28f + if (hypot(dxToPad, dyToPad) <= dpadRadius * 1.4f) { + val dz = dpadRadius * 0.24f if (dxToPad > dz) newDpadX = 1f else if (dxToPad < -dz) newDpadX = -1f if (dyToPad > dz) newDpadY = 1f else if (dyToPad < -dz) newDpadY = -1f continue } - for (button in allButtons) { - val bdx = x - button.cx - val bdy = y - button.cy - val reach = button.radius * 1.25f - if (bdx * bdx + bdy * bdy <= reach * reach) { - if (button.keyCode == 0) { - menuTouched = true - } else { - newPressed.add(button.keyCode) - } + if (hitButton(menuButton, x, y)) { + menuTouched = true + continue + } + + for (button in buttons) { + if (hitButton(button, x, y)) { + newPressed.add(button.keyCode) break } } } } + if (!stickSeen && stickPointerId != -1) { + stickPointerId = -1 + newStickX = 0f + newStickY = 0f + } + if (newStickX != stickX || newStickY != stickY) { + stickX = newStickX + stickY = newStickY + listener.onStick(stickX, stickY) + } + for (keyCode in pressedButtons) { if (!newPressed.contains(keyCode)) listener.onButton(keyCode, false) } From bedc10476278667091e5d547525ee736bdfded93 Mon Sep 17 00:00:00 2001 From: max Date: Mon, 13 Jul 2026 12:15:20 -0400 Subject: [PATCH 05/38] Fix retro overlay placement, drawer sizing parity, recency sort, and artwork - Move Start/Select pills above the A/B cluster and the Menu pill above the D-pad so they sit off the game picture (fixes GB narrow-screen overlap); N64 stacks trigger/bumper/menu/dpad/stick down the left edge - Match the in-game drawer to the PC xservermenu sizing: RetroActivity now extends FixedFontScaleAppCompatActivity like the PC session activity, the full-screen dim scrim is gone (transparent tap-to-close catcher like PC), and all drawer chrome scales with the same paneScale rule (height/520, clamped 0.78-1) the PC drawer uses - Record play stats for retro games (play count, last played, playtime) in the same playtime_stats keys PC games use, so the library's most-recently-played-first ordering now covers every backend - Retro settings General tab gains the PC-style Library Artwork section with Game Card / Grid / Carousel / List image pickers backed by the shared LibraryShortcutArtwork slots --- app/src/main/feature/retro/RetroActivity.kt | 33 ++- app/src/main/feature/retro/RetroDrawerMenu.kt | 252 ++++++++++-------- .../main/feature/retro/RetroGameSettings.kt | 140 +++++++++- app/src/main/feature/retro/RetroInputView.kt | 67 +++-- .../main/feature/retro/RetroSettingsDialog.kt | 53 +++- 5 files changed, 408 insertions(+), 137 deletions(-) diff --git a/app/src/main/feature/retro/RetroActivity.kt b/app/src/main/feature/retro/RetroActivity.kt index a3dd2a472..52c6cc027 100644 --- a/app/src/main/feature/retro/RetroActivity.kt +++ b/app/src/main/feature/retro/RetroActivity.kt @@ -1,5 +1,6 @@ package com.winlator.cmod.feature.retro +import android.content.SharedPreferences import android.os.Bundle import android.view.InputDevice import android.view.KeyEvent @@ -8,7 +9,6 @@ import android.view.View import android.view.WindowManager import android.widget.FrameLayout import android.widget.Toast -import androidx.appcompat.app.AppCompatActivity import androidx.compose.ui.platform.ComposeView import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat @@ -20,6 +20,7 @@ import com.swordfish.libretrodroid.ShaderConfig import com.swordfish.libretrodroid.Variable import com.winlator.cmod.runtime.container.ContainerManager import com.winlator.cmod.runtime.container.Shortcut +import com.winlator.cmod.shared.android.FixedFontScaleAppCompatActivity import com.winlator.cmod.shared.theme.WinNativeTheme import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.launchIn @@ -27,7 +28,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import java.io.File -class RetroActivity : AppCompatActivity(), RetroInputView.Listener { +class RetroActivity : FixedFontScaleAppCompatActivity(), RetroInputView.Listener { companion object { const val EXTRA_ROM_PATH = "retro_rom_path" const val EXTRA_SYSTEM_ID = "retro_system_id" @@ -57,6 +58,8 @@ class RetroActivity : AppCompatActivity(), RetroInputView.Listener { private var currentDisk = 0 private var system: RetroSystem? = null private var persistShortcut: Shortcut? = null + private var playtimePrefs: SharedPreferences? = null + private var sessionStart = 0L override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -164,10 +167,35 @@ class RetroActivity : AppCompatActivity(), RetroInputView.Listener { setContentView(root) retroReady = true + recordLaunchStats() observeErrors() observeEvents() } + private fun recordLaunchStats() { + val prefs = getSharedPreferences("playtime_stats", MODE_PRIVATE) + playtimePrefs = prefs + sessionStart = System.currentTimeMillis() + prefs + .edit() + .putInt("${gameName}_play_count", prefs.getInt("${gameName}_play_count", 0) + 1) + .putLong("${gameName}_last_played", sessionStart) + .apply() + } + + private fun accumulatePlaytime() { + val prefs = playtimePrefs ?: return + val now = System.currentTimeMillis() + val delta = now - sessionStart + if (delta > 0) { + prefs + .edit() + .putLong("${gameName}_playtime", prefs.getLong("${gameName}_playtime", 0L) + delta) + .apply() + } + sessionStart = now + } + private fun observeEvents() { retroView .getGLRetroEvents() @@ -483,6 +511,7 @@ class RetroActivity : AppCompatActivity(), RetroInputView.Listener { override fun onPause() { persistSram() + accumulatePlaytime() super.onPause() } diff --git a/app/src/main/feature/retro/RetroDrawerMenu.kt b/app/src/main/feature/retro/RetroDrawerMenu.kt index e19054550..bbd9f7d34 100644 --- a/app/src/main/feature/retro/RetroDrawerMenu.kt +++ b/app/src/main/feature/retro/RetroDrawerMenu.kt @@ -22,6 +22,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -276,65 +277,63 @@ class RetroMenuController { @Composable fun RetroDrawerMenu(controller: RetroMenuController) { val density = LocalDensity.current - AnimatedVisibility( - visible = controller.visible, - enter = fadeIn(tween(180)), - exit = fadeOut(tween(140)), - ) { - Box( - Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.32f)) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - ) { controller.close() }, - ) - } + BoxWithConstraints(Modifier.fillMaxSize()) { + val paneScale = (maxHeight.value / 520f).coerceIn(0.78f, 1f) + if (controller.visible) { + Box( + Modifier + .fillMaxSize() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) { controller.close() }, + ) + } - val closedOffset = -(DrawerWidth + DrawerStartPadding + 8.dp) - val sheetOffset by animateDpAsState( - targetValue = if (controller.visible) 0.dp else closedOffset, - animationSpec = tween(durationMillis = 200, easing = LinearEasing), - label = "retroDrawerOffset", - ) - if (sheetOffset > closedOffset) { - Box( - Modifier - .padding(start = DrawerStartPadding, top = DrawerVerticalPadding, bottom = DrawerVerticalPadding) - .fillMaxHeight() - .width(DrawerWidth) - .offset { androidx.compose.ui.unit.IntOffset(with(density) { sheetOffset.roundToPx() }, 0) } - .clip(RoundedCornerShape(20.dp)) - .background(RetroSheetColor) - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - ) {}, - ) { - Column(Modifier.fillMaxSize()) { - RetroTopRail(controller) - ThinDivider() - Box(Modifier.weight(1f).fillMaxWidth()) { - AnimatedContent( - targetState = controller.pane, - transitionSpec = { - fadeIn(tween(220, easing = FastOutSlowInEasing)) togetherWith - fadeOut(tween(220, easing = FastOutSlowInEasing)) - }, - label = "retroDrawerBody", - ) { pane -> - if (pane == null) { - RetroActionGrid(controller) - } else { - RetroPaneList(controller) + val closedOffset = -(DrawerWidth + DrawerStartPadding + 8.dp) + val sheetOffset by animateDpAsState( + targetValue = if (controller.visible) 0.dp else closedOffset, + animationSpec = tween(durationMillis = 200, easing = LinearEasing), + label = "retroDrawerOffset", + ) + if (sheetOffset > closedOffset) { + Box( + Modifier + .padding(start = DrawerStartPadding, top = DrawerVerticalPadding, bottom = DrawerVerticalPadding) + .fillMaxHeight() + .width(DrawerWidth) + .offset { androidx.compose.ui.unit.IntOffset(with(density) { sheetOffset.roundToPx() }, 0) } + .clip(RoundedCornerShape(20.dp)) + .background(RetroSheetColor) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + ) {}, + ) { + Column(Modifier.fillMaxSize()) { + RetroTopRail(controller, paneScale) + ThinDivider() + Box(Modifier.weight(1f).fillMaxWidth()) { + AnimatedContent( + targetState = controller.pane, + transitionSpec = { + fadeIn(tween(220, easing = FastOutSlowInEasing)) togetherWith + fadeOut(tween(220, easing = FastOutSlowInEasing)) + }, + label = "retroDrawerBody", + ) { pane -> + if (pane == null) { + RetroActionGrid(controller, paneScale) + } else { + RetroPaneList(controller, paneScale) + } } } - } - AnimatedVisibility(visible = controller.pane == null) { - Column { - ThinDivider() - RetroBottomExit(controller) + AnimatedVisibility(visible = controller.pane == null) { + Column { + ThinDivider() + RetroBottomExit(controller, paneScale) + } } } } @@ -349,7 +348,10 @@ private data class RailTileBounds( ) @Composable -private fun RetroTopRail(controller: RetroMenuController) { +private fun RetroTopRail( + controller: RetroMenuController, + paneScale: Float, +) { val density = LocalDensity.current val tileBounds = remember { mutableStateMapOf() } val railScroll = rememberScrollState() @@ -389,18 +391,23 @@ private fun RetroTopRail(controller: RetroMenuController) { Modifier .fillMaxWidth() .background(TopRailSurfaceColor) - .padding(start = 10.dp, end = 10.dp, top = 5.dp, bottom = 2.dp), + .padding( + start = (10f * paneScale).dp, + end = (10f * paneScale).dp, + top = (5f * paneScale).dp, + bottom = (2f * paneScale).dp, + ), ) { if (selectedBounds != null) { Box( modifier = Modifier .offset( - x = indicatorX - with(density) { railScroll.value.toDp() } + 6.dp, - y = indicatorTileHeight - 2.dp, + x = indicatorX - with(density) { railScroll.value.toDp() } + (6f * paneScale).dp, + y = indicatorTileHeight - (2f * paneScale).dp, ) - .width((indicatorWidth - 12.dp).coerceAtLeast(0.dp)) - .height(2.dp) + .width((indicatorWidth - (12f * paneScale).dp).coerceAtLeast(0.dp)) + .height((2f * paneScale).dp) .graphicsLayer { alpha = indicatorAlpha } .clip(RoundedCornerShape(1.dp)) .background(DrawerAccent), @@ -408,7 +415,7 @@ private fun RetroTopRail(controller: RetroMenuController) { } Row( modifier = Modifier.horizontalScroll(railScroll), - horizontalArrangement = Arrangement.spacedBy(10.dp), + horizontalArrangement = Arrangement.spacedBy((10f * paneScale).dp), verticalAlignment = Alignment.CenterVertically, ) { controller.tabs.forEachIndexed { index, tab -> @@ -422,6 +429,7 @@ private fun RetroTopRail(controller: RetroMenuController) { controller.pane == tab.pane }, highlighted = controller.controllerActive && controller.region == 0 && controller.railIndex == index, + paneScale = paneScale, onBoundsChanged = { tileBounds[index] = it }, onClick = { controller.railIndex = index @@ -439,6 +447,7 @@ private fun RetroRailTile( label: String, selected: Boolean, highlighted: Boolean, + paneScale: Float, onBoundsChanged: (RailTileBounds) -> Unit, onClick: () -> Unit, ) { @@ -464,11 +473,12 @@ private fun RetroRailTile( animationSpec = tween(120), label = "retroTileTint", ) - val shape = RoundedCornerShape(12.dp) + val cornerRadius = (12f * paneScale).dp + val shape = RoundedCornerShape(cornerRadius) Column( modifier = Modifier - .defaultMinSize(minWidth = 60.dp) + .defaultMinSize(minWidth = (60f * paneScale).dp) .onGloballyPositioned { coords -> val bounds = coords.boundsInParent() onBoundsChanged(RailTileBounds(bounds.left, bounds.width, bounds.height)) @@ -481,22 +491,27 @@ private fun RetroRailTile( .background(bgColor) .then( if (highlighted) { - Modifier.chasingBorder(cornerRadius = 12.dp, borderWidth = 1.5.dp, animationDurationMs = 8200) + Modifier.chasingBorder(cornerRadius = cornerRadius, borderWidth = 1.5.dp, animationDurationMs = 8200) } else { Modifier }, ) .clickable(interactionSource = interactionSource, indication = null, onClick = onClick) - .padding(start = 10.dp, end = 10.dp, top = 10.dp, bottom = 7.dp), + .padding( + start = (10f * paneScale).dp, + end = (10f * paneScale).dp, + top = (10f * paneScale).dp, + bottom = (7f * paneScale).dp, + ), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - Icon(imageVector = icon, contentDescription = label, tint = tint, modifier = Modifier.size(22.dp)) - Spacer(Modifier.height(2.dp)) + Icon(imageVector = icon, contentDescription = label, tint = tint, modifier = Modifier.size((22f * paneScale).dp)) + Spacer(Modifier.height((2f * paneScale).dp)) Text( text = label, color = DrawerTextPrimary, - fontSize = 12.sp, + fontSize = (12f * paneScale).sp, fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium, letterSpacing = 0.2.sp, maxLines = 1, @@ -506,20 +521,23 @@ private fun RetroRailTile( } @Composable -private fun RetroActionGrid(controller: RetroMenuController) { +private fun RetroActionGrid( + controller: RetroMenuController, + paneScale: Float, +) { val actions = controller.entries Column( modifier = Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) - .padding(horizontal = 10.dp, vertical = 10.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + .padding(horizontal = (10f * paneScale).dp, vertical = (10f * paneScale).dp), + verticalArrangement = Arrangement.spacedBy((8f * paneScale).dp), ) { actions.chunked(controller.gridColumns).forEachIndexed { rowIndex, row -> Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy((8f * paneScale).dp), ) { row.forEachIndexed { colIndex, entry -> val flatIndex = rowIndex * controller.gridColumns + colIndex @@ -530,7 +548,8 @@ private fun RetroActionGrid(controller: RetroMenuController) { controller.controllerActive && controller.region == 1 && controller.contentIndex == flatIndex, - modifier = Modifier.weight(1f).height(64.dp), + paneScale = paneScale, + modifier = Modifier.weight(1f).height((64f * paneScale).dp), onClick = { controller.contentIndex = flatIndex entry.onClick() @@ -550,6 +569,7 @@ private fun RetroActionGrid(controller: RetroMenuController) { private fun RetroActionCard( entry: RetroMenuEntry.Action, highlighted: Boolean, + paneScale: Float, modifier: Modifier = Modifier, onClick: () -> Unit, ) { @@ -581,7 +601,8 @@ private fun RetroActionCard( entry.active -> DrawerActiveAccent else -> DrawerTextPrimary } - val shape = RoundedCornerShape(12.dp) + val cornerRadius = (12f * paneScale).dp + val shape = RoundedCornerShape(cornerRadius) val topColor = Color( red = (bgColor.red + (1f - bgColor.red) * DrawerGradientLift).coerceIn(0f, 1f), @@ -601,7 +622,7 @@ private fun RetroActionCard( .border(1.dp, borderColor, shape) .then( if (highlighted) { - Modifier.chasingBorder(cornerRadius = 12.dp, borderWidth = 1.5.dp, animationDurationMs = 8200) + Modifier.chasingBorder(cornerRadius = cornerRadius, borderWidth = 1.5.dp, animationDurationMs = 8200) } else { Modifier }, @@ -610,12 +631,17 @@ private fun RetroActionCard( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - Icon(imageVector = entry.icon, contentDescription = entry.label, tint = tint, modifier = Modifier.size(24.dp)) - Spacer(Modifier.height(4.dp)) + Icon( + imageVector = entry.icon, + contentDescription = entry.label, + tint = tint, + modifier = Modifier.size((24f * paneScale).dp), + ) + Spacer(Modifier.height((4f * paneScale).dp)) Text( text = entry.label, color = DrawerTextPrimary, - fontSize = 12.sp, + fontSize = (12f * paneScale).sp, fontWeight = if (entry.active) FontWeight.SemiBold else FontWeight.Medium, letterSpacing = 0.2.sp, textAlign = TextAlign.Center, @@ -626,14 +652,17 @@ private fun RetroActionCard( } @Composable -private fun RetroPaneList(controller: RetroMenuController) { +private fun RetroPaneList( + controller: RetroMenuController, + paneScale: Float, +) { Column( modifier = Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) - .padding(horizontal = 10.dp, vertical = 10.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + .padding(horizontal = (10f * paneScale).dp, vertical = (10f * paneScale).dp), + verticalArrangement = Arrangement.spacedBy((8f * paneScale).dp), ) { controller.entries.forEachIndexed { index, entry -> val highlighted = @@ -645,6 +674,7 @@ private fun RetroPaneList(controller: RetroMenuController) { RetroBooleanRow( entry = entry, highlighted = highlighted, + paneScale = paneScale, onClick = { controller.contentIndex = index entry.onChange(!entry.checked) @@ -654,6 +684,7 @@ private fun RetroPaneList(controller: RetroMenuController) { RetroChoiceRow( entry = entry, highlighted = highlighted, + paneScale = paneScale, onClick = { controller.contentIndex = index entry.onCycle(1) @@ -663,6 +694,7 @@ private fun RetroPaneList(controller: RetroMenuController) { RetroRadioRow( entry = entry, highlighted = highlighted, + paneScale = paneScale, onClick = { controller.contentIndex = index entry.onSelect() @@ -672,7 +704,8 @@ private fun RetroPaneList(controller: RetroMenuController) { RetroActionCard( entry = entry, highlighted = highlighted, - modifier = Modifier.fillMaxWidth().height(56.dp), + paneScale = paneScale, + modifier = Modifier.fillMaxWidth().height((56f * paneScale).dp), onClick = { controller.contentIndex = index entry.onClick() @@ -687,6 +720,7 @@ private fun RetroPaneList(controller: RetroMenuController) { private fun RetroRowShell( highlighted: Boolean, activeBorder: Boolean, + paneScale: Float, onClick: () -> Unit, content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit, ) { @@ -707,7 +741,8 @@ private fun RetroRowShell( animationSpec = tween(140), label = "retroRowBorder", ) - val shape = RoundedCornerShape(14.dp) + val cornerRadius = (14f * paneScale).dp + val shape = RoundedCornerShape(cornerRadius) Row( modifier = Modifier @@ -717,13 +752,13 @@ private fun RetroRowShell( .border(1.dp, borderColor, shape) .then( if (highlighted) { - Modifier.chasingBorder(cornerRadius = 14.dp, borderWidth = 1.5.dp, animationDurationMs = 8200) + Modifier.chasingBorder(cornerRadius = cornerRadius, borderWidth = 1.5.dp, animationDurationMs = 8200) } else { Modifier }, ) .clickable(interactionSource = interactionSource, indication = null, onClick = onClick) - .padding(horizontal = 12.dp, vertical = 8.dp), + .padding(horizontal = (12f * paneScale).dp, vertical = (8f * paneScale).dp), verticalAlignment = Alignment.CenterVertically, ) { content() @@ -734,21 +769,22 @@ private fun RetroRowShell( private fun RetroBooleanRow( entry: RetroMenuEntry.Toggle, highlighted: Boolean, + paneScale: Float, onClick: () -> Unit, ) { - RetroRowShell(highlighted = highlighted, activeBorder = entry.checked, onClick = onClick) { + RetroRowShell(highlighted = highlighted, activeBorder = entry.checked, paneScale = paneScale, onClick = onClick) { Column(modifier = Modifier.weight(1f)) { Text( text = entry.label, color = DrawerTextPrimary, - fontSize = 14.sp, + fontSize = (14f * paneScale).sp, fontWeight = FontWeight.Medium, ) Spacer(Modifier.height(2.dp)) Text( text = entry.subtitle ?: if (entry.checked) "Enabled" else "Disabled", color = DrawerTextSecondary, - fontSize = 12.sp, + fontSize = (12f * paneScale).sp, ) } Switch( @@ -763,28 +799,29 @@ private fun RetroBooleanRow( private fun RetroChoiceRow( entry: RetroMenuEntry.Choice, highlighted: Boolean, + paneScale: Float, onClick: () -> Unit, ) { - RetroRowShell(highlighted = highlighted, activeBorder = false, onClick = onClick) { + RetroRowShell(highlighted = highlighted, activeBorder = false, paneScale = paneScale, onClick = onClick) { Column(modifier = Modifier.weight(1f)) { Text( text = entry.label, color = DrawerTextPrimary, - fontSize = 14.sp, + fontSize = (14f * paneScale).sp, fontWeight = FontWeight.Medium, ) Spacer(Modifier.height(2.dp)) Text( text = entry.valueLabel, color = DrawerActiveAccent, - fontSize = 12.sp, + fontSize = (12f * paneScale).sp, fontWeight = FontWeight.SemiBold, ) } Text( text = "‹ ›", color = DrawerTextSecondary, - fontSize = 16.sp, + fontSize = (16f * paneScale).sp, ) } } @@ -793,13 +830,14 @@ private fun RetroChoiceRow( private fun RetroRadioRow( entry: RetroMenuEntry.Radio, highlighted: Boolean, + paneScale: Float, onClick: () -> Unit, ) { - RetroRowShell(highlighted = highlighted, activeBorder = entry.selected, onClick = onClick) { + RetroRowShell(highlighted = highlighted, activeBorder = entry.selected, paneScale = paneScale, onClick = onClick) { Text( text = entry.label, color = if (entry.selected) DrawerActiveAccent else DrawerTextPrimary, - fontSize = 14.sp, + fontSize = (14f * paneScale).sp, fontWeight = if (entry.selected) FontWeight.SemiBold else FontWeight.Medium, modifier = Modifier.weight(1f), ) @@ -807,7 +845,7 @@ private fun RetroRadioRow( Text( text = "✓", color = DrawerActiveAccent, - fontSize = 14.sp, + fontSize = (14f * paneScale).sp, fontWeight = FontWeight.SemiBold, ) } @@ -815,7 +853,10 @@ private fun RetroRadioRow( } @Composable -private fun RetroBottomExit(controller: RetroMenuController) { +private fun RetroBottomExit( + controller: RetroMenuController, + paneScale: Float, +) { val interactionSource = remember { MutableInteractionSource() } val pressed = interactionSource.collectIsPressedAsState().value val highlighted = controller.controllerActive && controller.region == 2 @@ -829,12 +870,13 @@ private fun RetroBottomExit(controller: RetroMenuController) { animationSpec = tween(120), label = "retroExitBg", ) - val shape = RoundedCornerShape(14.dp) + val cornerRadius = (14f * paneScale).dp + val shape = RoundedCornerShape(cornerRadius) Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 10.dp, vertical = 8.dp), + .padding(horizontal = (10f * paneScale).dp, vertical = (8f * paneScale).dp), ) { Row( modifier = @@ -845,7 +887,7 @@ private fun RetroBottomExit(controller: RetroMenuController) { .border(1.dp, GlassExitTint.copy(alpha = 0.34f), shape) .then( if (highlighted) { - Modifier.chasingBorder(cornerRadius = 14.dp, borderWidth = 1.5.dp, animationDurationMs = 8200) + Modifier.chasingBorder(cornerRadius = cornerRadius, borderWidth = 1.5.dp, animationDurationMs = 8200) } else { Modifier }, @@ -853,7 +895,7 @@ private fun RetroBottomExit(controller: RetroMenuController) { .clickable(interactionSource = interactionSource, indication = null) { controller.onExit?.invoke() } - .padding(horizontal = 12.dp, vertical = 10.dp), + .padding(horizontal = (12f * paneScale).dp, vertical = (10f * paneScale).dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, ) { @@ -861,13 +903,13 @@ private fun RetroBottomExit(controller: RetroMenuController) { imageVector = Icons.AutoMirrored.Outlined.ExitToApp, contentDescription = null, tint = GlassExitTint, - modifier = Modifier.size(18.dp), + modifier = Modifier.size((18f * paneScale).dp), ) - Spacer(Modifier.width(8.dp)) + Spacer(Modifier.width((8f * paneScale).dp)) Text( text = "Exit", color = GlassExitTint, - fontSize = 13.sp, + fontSize = (13f * paneScale).sp, fontWeight = FontWeight.SemiBold, maxLines = 1, ) diff --git a/app/src/main/feature/retro/RetroGameSettings.kt b/app/src/main/feature/retro/RetroGameSettings.kt index 8d186ec9a..6aefae5ac 100644 --- a/app/src/main/feature/retro/RetroGameSettings.kt +++ b/app/src/main/feature/retro/RetroGameSettings.kt @@ -59,7 +59,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.PopupProperties import com.winlator.cmod.feature.library.GameSettingsNav +import com.winlator.cmod.feature.shortcuts.LibraryShortcutArtwork import com.winlator.cmod.runtime.container.Shortcut +import java.io.File import com.winlator.cmod.shared.ui.nav.LocalPaneNav import com.winlator.cmod.shared.ui.nav.PaneNavRegistry import com.winlator.cmod.shared.ui.nav.paneHighlight @@ -80,6 +82,7 @@ private val TextSecondary = Color(0xFF7A8FA8) private val TextDim = Color(0xFF6E7681) private val DividerColor = Color(0xFF2A2A3A) private val NavHighlight = Color(0xFF4FC3F7) +private val DangerRed = Color(0xFFFF6B6B) private val LabelSize = 11.sp private val ValueSize = 12.sp @@ -116,8 +119,20 @@ class RetroSettingsState( ) } } + val artworkSelected = mutableStateMapOf() var currentSection by mutableIntStateOf(0) + init { + syncArtwork() + } + + fun syncArtwork() { + LibraryShortcutArtwork.LibraryArtworkSlot.entries.forEach { slot -> + val path = shortcut.getExtra(slot.extraKey) + artworkSelected[slot] = path.isNotBlank() && File(path).isFile + } + } + fun save() { shortcut.putExtra(RetroShortcuts.KEY_SHADER, shader) shortcut.putExtra(RetroShortcuts.KEY_TOUCH_CONTROLS, if (touchControls) "1" else "0") @@ -153,6 +168,8 @@ private fun buildRetroSections(state: RetroSettingsState): List { fun RetroGameSettingsContent( state: RetroSettingsState, nav: GameSettingsNav? = null, + onPickArtwork: ((LibraryShortcutArtwork.LibraryArtworkSlot) -> Unit)? = null, + onRemoveArtwork: ((LibraryShortcutArtwork.LibraryArtworkSlot) -> Unit)? = null, onSave: () -> Unit, onCancel: () -> Unit, ) { @@ -201,6 +218,8 @@ fun RetroGameSettingsContent( hasSystemSection = hasSystemSection, state = state, nav = nav, + onPickArtwork = onPickArtwork, + onRemoveArtwork = onRemoveArtwork, ) } } @@ -213,6 +232,8 @@ private fun RetroSectionContent( hasSystemSection: Boolean, state: RetroSettingsState, nav: GameSettingsNav? = null, + onPickArtwork: ((LibraryShortcutArtwork.LibraryArtworkSlot) -> Unit)? = null, + onRemoveArtwork: ((LibraryShortcutArtwork.LibraryArtworkSlot) -> Unit)? = null, ) { AnimatedContent( targetState = sectionIndex, @@ -260,7 +281,7 @@ private fun RetroSectionContent( ) { val systemIdx = 2 when { - idx == 0 -> RetroGeneralSection(state) + idx == 0 -> RetroGeneralSection(state, onPickArtwork, onRemoveArtwork) idx == 1 -> RetroGraphicsSection(state) hasSystemSection && idx == systemIdx -> RetroSystemSection(state) idx == (if (hasSystemSection) 3 else 2) -> RetroInputSection(state) @@ -655,7 +676,11 @@ private fun RetroSettingSwitch( } @Composable -private fun RetroGeneralSection(state: RetroSettingsState) { +private fun RetroGeneralSection( + state: RetroSettingsState, + onPickArtwork: ((LibraryShortcutArtwork.LibraryArtworkSlot) -> Unit)? = null, + onRemoveArtwork: ((LibraryShortcutArtwork.LibraryArtworkSlot) -> Unit)? = null, +) { RetroSettingGroup { RetroGroupTitle("GAME") RetroInfoRow("Name", state.name) @@ -663,6 +688,117 @@ private fun RetroGeneralSection(state: RetroSettingsState) { RetroInfoRow("Emulator Core", state.system?.coreFileName ?: "") RetroInfoRow("ROM Path", state.romPath) } + if (onPickArtwork != null && onRemoveArtwork != null) { + Spacer(Modifier.height(ItemGap)) + RetroSettingGroup { + RetroGroupTitle("LIBRARY ARTWORK") + RetroArtworkRow( + title = "Game Card Image", + selected = state.artworkSelected[LibraryShortcutArtwork.LibraryArtworkSlot.GAME_CARD] == true, + onPick = { onPickArtwork(LibraryShortcutArtwork.LibraryArtworkSlot.GAME_CARD) }, + onRemove = { onRemoveArtwork(LibraryShortcutArtwork.LibraryArtworkSlot.GAME_CARD) }, + ) + Spacer(Modifier.height(ItemGap)) + RetroArtworkRow( + title = "Grid Image", + selected = state.artworkSelected[LibraryShortcutArtwork.LibraryArtworkSlot.GRID] == true, + onPick = { onPickArtwork(LibraryShortcutArtwork.LibraryArtworkSlot.GRID) }, + onRemove = { onRemoveArtwork(LibraryShortcutArtwork.LibraryArtworkSlot.GRID) }, + ) + Spacer(Modifier.height(ItemGap)) + RetroArtworkRow( + title = "Carousel Image", + selected = state.artworkSelected[LibraryShortcutArtwork.LibraryArtworkSlot.CAROUSEL] == true, + onPick = { onPickArtwork(LibraryShortcutArtwork.LibraryArtworkSlot.CAROUSEL) }, + onRemove = { onRemoveArtwork(LibraryShortcutArtwork.LibraryArtworkSlot.CAROUSEL) }, + ) + Spacer(Modifier.height(ItemGap)) + RetroArtworkRow( + title = "List Image", + selected = state.artworkSelected[LibraryShortcutArtwork.LibraryArtworkSlot.LIST] == true, + onPick = { onPickArtwork(LibraryShortcutArtwork.LibraryArtworkSlot.LIST) }, + onRemove = { onRemoveArtwork(LibraryShortcutArtwork.LibraryArtworkSlot.LIST) }, + ) + } + } +} + +@Composable +private fun RetroArtworkRow( + title: String, + selected: Boolean, + onPick: () -> Unit, + onRemove: () -> Unit, +) { + Box( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(GroupCorner)) + .background(InputSurface) + .border(1.dp, InputBorder, RoundedCornerShape(GroupCorner)) + .padding(horizontal = 10.dp, vertical = 8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + color = TextPrimary, + fontSize = ValueSize, + fontWeight = FontWeight.SemiBold, + ) + if (selected) { + Spacer(Modifier.height(2.dp)) + Text( + text = "Custom image set", + color = TextSecondary, + fontSize = LabelSize, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (!selected) { + RetroArtworkActionButton("Set image", AccentBlue, onPick) + } else { + RetroArtworkActionButton("Remove", DangerRed, onRemove) + } + } + } + } +} + +@Composable +private fun RetroArtworkActionButton( + text: String, + tint: Color, + onClick: () -> Unit, +) { + Box( + modifier = + Modifier + .clip(RoundedCornerShape(9.dp)) + .background(tint.copy(alpha = 0.08f)) + .border(1.dp, tint.copy(alpha = 0.2f), RoundedCornerShape(9.dp)) + .paneNavItem(cornerRadius = 9.dp, onActivate = { onClick() }, highlightColor = NavHighlight) + .clickable { onClick() } + .padding(horizontal = 10.dp, vertical = 6.dp), + ) { + Text( + text = text, + color = tint, + fontSize = LabelSize, + fontWeight = FontWeight.Medium, + ) + } } @Composable diff --git a/app/src/main/feature/retro/RetroInputView.kt b/app/src/main/feature/retro/RetroInputView.kt index 529c64b77..17969909e 100644 --- a/app/src/main/feature/retro/RetroInputView.kt +++ b/app/src/main/feature/retro/RetroInputView.kt @@ -132,32 +132,34 @@ class RetroInputView( val trigH = unit * 0.105f val trigGap = unit * 0.02f - var leftShoulderTop = margin - var rightShoulderTop = margin + var leftCursor = margin + var rightCursor = margin if (config.hasTriggers) { val lt = GlassButton(KeyEvent.KEYCODE_BUTTON_L2, config.leftTriggerLabel, GlassShape.TRIGGER_LT) - lt.bounds.set(margin, leftShoulderTop, margin + trigW, leftShoulderTop + trigH) + lt.bounds.set(margin, leftCursor, margin + trigW, leftCursor + trigH) buttons += lt - leftShoulderTop += trigH + trigGap + leftCursor += trigH + trigGap if (config.showRightTrigger) { val rt = GlassButton(KeyEvent.KEYCODE_BUTTON_R2, config.rightTriggerLabel, GlassShape.TRIGGER_RT) - rt.bounds.set(width - margin - trigW, rightShoulderTop, width - margin, rightShoulderTop + trigH) + rt.bounds.set(width - margin - trigW, rightCursor, width - margin, rightCursor + trigH) buttons += rt - rightShoulderTop += trigH + trigGap + rightCursor += trigH + trigGap } } if (config.hasShoulders) { val lb = GlassButton(KeyEvent.KEYCODE_BUTTON_L1, "L", GlassShape.TRIGGER_LB) - lb.bounds.set(margin, leftShoulderTop, margin + trigW, leftShoulderTop + trigH) + lb.bounds.set(margin, leftCursor, margin + trigW, leftCursor + trigH) buttons += lb val rb = GlassButton(KeyEvent.KEYCODE_BUTTON_R1, "R", GlassShape.TRIGGER_RB) - rb.bounds.set(width - margin - trigW, rightShoulderTop, width - margin, rightShoulderTop + trigH) + rb.bounds.set(width - margin - trigW, rightCursor, width - margin, rightCursor + trigH) buttons += rb + leftCursor += trigH + trigGap } val clusterCx = width - margin - faceRadius * 2.6f val clusterCy = height - margin - faceRadius * 2.6f val spread = faceRadius * 1.75f + var clusterTop = height fun addFace( keyCode: Int, label: String, @@ -167,6 +169,7 @@ class RetroInputView( val button = GlassButton(keyCode, label, GlassShape.CIRCLE) button.bounds.set(cx - faceRadius, cy - faceRadius, cx + faceRadius, cy + faceRadius) buttons += button + clusterTop = min(clusterTop, button.bounds.top) } if (config.hasXY) { addFace(KeyEvent.KEYCODE_BUTTON_X, "X", clusterCx, clusterCy - spread) @@ -178,31 +181,41 @@ class RetroInputView( addFace(KeyEvent.KEYCODE_BUTTON_A, "A", clusterCx + faceRadius * 1.1f, clusterCy - faceRadius * 0.7f) } - dpadRadius = unit * 0.155f + val pillW = unit * 0.135f + val pillH = unit * 0.062f + val pillGap = unit * 0.02f + val pillY = clusterTop - pillH - unit * 0.025f + val start = GlassButton(KeyEvent.KEYCODE_BUTTON_START, "START", GlassShape.PILL) + start.bounds.set(width - margin - pillW, pillY, width - margin, pillY + pillH) + buttons += start + val select = GlassButton(KeyEvent.KEYCODE_BUTTON_SELECT, "SELECT", GlassShape.PILL) + select.bounds.set( + width - margin - pillW * 2 - pillGap, + pillY, + width - margin - pillW - pillGap, + pillY + pillH, + ) + buttons += select + + val menuW = unit * 0.15f if (config.hasStick) { - stickRadius = unit * 0.13f - stickCx = margin + stickRadius * 1.4f - stickCy = height - margin - stickRadius - dpadCx = margin + dpadRadius * 0.9f - dpadCy = stickCy - stickRadius - dpadRadius - unit * 0.04f + dpadRadius = unit * 0.135f + dpadCx = margin + dpadRadius + unit * 0.02f + menuButton.bounds.set(dpadCx - menuW * 0.5f, leftCursor, dpadCx + menuW * 0.5f, leftCursor + pillH) + leftCursor += pillH + trigGap + dpadCy = leftCursor + dpadRadius + leftCursor += dpadRadius * 2 + trigGap + stickRadius = unit * 0.115f + stickCx = dpadCx + stickCy = max(leftCursor + stickRadius, height - margin - stickRadius) } else { stickRadius = 0f + dpadRadius = unit * 0.155f dpadCx = margin + dpadRadius * 1.15f dpadCy = height - margin - dpadRadius * 1.15f + val menuY = max(dpadCy - dpadRadius - pillH - unit * 0.025f, leftCursor) + menuButton.bounds.set(dpadCx - menuW * 0.5f, menuY, dpadCx + menuW * 0.5f, menuY + pillH) } - - val pillW = unit * 0.17f - val pillH = unit * 0.062f - val pillGap = unit * 0.02f - val pillY = height - margin - pillH - val select = GlassButton(KeyEvent.KEYCODE_BUTTON_SELECT, "SELECT", GlassShape.PILL) - select.bounds.set(width * 0.5f - pillGap * 0.5f - pillW, pillY, width * 0.5f - pillGap * 0.5f, pillY + pillH) - buttons += select - val start = GlassButton(KeyEvent.KEYCODE_BUTTON_START, "START", GlassShape.PILL) - start.bounds.set(width * 0.5f + pillGap * 0.5f, pillY, width * 0.5f + pillGap * 0.5f + pillW, pillY + pillH) - buttons += start - - menuButton.bounds.set(width * 0.5f - pillW * 0.5f, margin, width * 0.5f + pillW * 0.5f, margin + pillH) } override fun onDraw(canvas: Canvas) { diff --git a/app/src/main/feature/retro/RetroSettingsDialog.kt b/app/src/main/feature/retro/RetroSettingsDialog.kt index 89b3043ad..32d91b77e 100644 --- a/app/src/main/feature/retro/RetroSettingsDialog.kt +++ b/app/src/main/feature/retro/RetroSettingsDialog.kt @@ -2,10 +2,15 @@ package com.winlator.cmod.feature.retro import android.app.Activity import android.app.Dialog +import android.net.Uri import android.os.Build import android.view.ViewGroup import android.view.Window import android.view.WindowManager +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalDensity @@ -17,19 +22,60 @@ import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner import com.winlator.cmod.R import com.winlator.cmod.feature.library.GameSettingsNav +import com.winlator.cmod.feature.shortcuts.LibraryShortcutArtwork import com.winlator.cmod.runtime.container.Shortcut +import com.winlator.cmod.shared.android.ImageUtils +import com.winlator.cmod.shared.io.FileUtils import com.winlator.cmod.shared.theme.WinNativeTheme import com.winlator.cmod.shared.ui.nav.PANE_DIR_ACTIVATE import com.winlator.cmod.shared.ui.nav.PaneNavWindowHandlers import com.winlator.cmod.shared.ui.nav.bindPaneNav +import com.winlator.cmod.shared.ui.toast.WinToast class RetroSettingsDialog( private val activity: Activity, - shortcut: Shortcut, + private val shortcut: Shortcut, ) { private val state = RetroSettingsState(shortcut) private val nav = GameSettingsNav() private var restorePaneNav: (() -> Unit)? = null + private var pendingArtworkSlot = LibraryShortcutArtwork.LibraryArtworkSlot.GAME_CARD + + private val artworkPickerLauncher: ActivityResultLauncher>? = + (activity as? ComponentActivity)?.activityResultRegistry?.register( + "retro_artwork_picker", + ActivityResultContracts.OpenDocument(), + ) { uri: Uri? -> + if (uri != null) saveSelectedArtwork(uri) + } + + private fun saveSelectedArtwork(uri: Uri) { + val bitmap = ImageUtils.getBitmapFromUri(activity, uri, 1024) + if (bitmap == null) { + WinToast.show(activity, R.string.shortcuts_library_artwork_failed, Toast.LENGTH_SHORT) + return + } + val slot = pendingArtworkSlot + val previousPath = shortcut.getExtra(slot.extraKey) + val outputFile = LibraryShortcutArtwork.buildManagedViewArtworkFile(activity, shortcut, slot) + if (!FileUtils.saveBitmapToFile(bitmap, outputFile)) { + WinToast.show(activity, R.string.shortcuts_library_artwork_failed, Toast.LENGTH_SHORT) + return + } + if (previousPath.isNotBlank() && previousPath != outputFile.absolutePath) { + LibraryShortcutArtwork.deleteManagedArtwork(activity, previousPath) + } + shortcut.putExtra(slot.extraKey, outputFile.absolutePath) + shortcut.saveData() + state.syncArtwork() + } + + private fun clearArtwork(slot: LibraryShortcutArtwork.LibraryArtworkSlot) { + LibraryShortcutArtwork.deleteManagedArtwork(activity, shortcut.getExtra(slot.extraKey)) + shortcut.putExtra(slot.extraKey, null) + shortcut.saveData() + state.syncArtwork() + } private val dialog: Dialog = Dialog(activity, R.style.ContentDialog).apply { @@ -75,6 +121,11 @@ class RetroSettingsDialog( RetroGameSettingsContent( state = state, nav = nav, + onPickArtwork = { slot -> + pendingArtworkSlot = slot + artworkPickerLauncher?.launch(arrayOf("image/*")) + }, + onRemoveArtwork = { slot -> clearArtwork(slot) }, onSave = { state.save() dialog.dismiss() From 5ae45bb253a2b42849329156b40fcb163796c32e Mon Sep 17 00:00:00 2001 From: max Date: Mon, 13 Jul 2026 12:17:40 -0400 Subject: [PATCH 06/38] Lay out retro Library Artwork pickers in a 2x2 grid like PC settings --- .../main/feature/retro/RetroGameSettings.kt | 62 +++++++++++-------- 1 file changed, 36 insertions(+), 26 deletions(-) diff --git a/app/src/main/feature/retro/RetroGameSettings.kt b/app/src/main/feature/retro/RetroGameSettings.kt index 6aefae5ac..68fbf6d03 100644 --- a/app/src/main/feature/retro/RetroGameSettings.kt +++ b/app/src/main/feature/retro/RetroGameSettings.kt @@ -692,33 +692,43 @@ private fun RetroGeneralSection( Spacer(Modifier.height(ItemGap)) RetroSettingGroup { RetroGroupTitle("LIBRARY ARTWORK") - RetroArtworkRow( - title = "Game Card Image", - selected = state.artworkSelected[LibraryShortcutArtwork.LibraryArtworkSlot.GAME_CARD] == true, - onPick = { onPickArtwork(LibraryShortcutArtwork.LibraryArtworkSlot.GAME_CARD) }, - onRemove = { onRemoveArtwork(LibraryShortcutArtwork.LibraryArtworkSlot.GAME_CARD) }, - ) - Spacer(Modifier.height(ItemGap)) - RetroArtworkRow( - title = "Grid Image", - selected = state.artworkSelected[LibraryShortcutArtwork.LibraryArtworkSlot.GRID] == true, - onPick = { onPickArtwork(LibraryShortcutArtwork.LibraryArtworkSlot.GRID) }, - onRemove = { onRemoveArtwork(LibraryShortcutArtwork.LibraryArtworkSlot.GRID) }, - ) - Spacer(Modifier.height(ItemGap)) - RetroArtworkRow( - title = "Carousel Image", - selected = state.artworkSelected[LibraryShortcutArtwork.LibraryArtworkSlot.CAROUSEL] == true, - onPick = { onPickArtwork(LibraryShortcutArtwork.LibraryArtworkSlot.CAROUSEL) }, - onRemove = { onRemoveArtwork(LibraryShortcutArtwork.LibraryArtworkSlot.CAROUSEL) }, - ) + Row(horizontalArrangement = Arrangement.spacedBy(ItemGap)) { + Box(Modifier.weight(1f)) { + RetroArtworkRow( + title = "Game Card Image", + selected = state.artworkSelected[LibraryShortcutArtwork.LibraryArtworkSlot.GAME_CARD] == true, + onPick = { onPickArtwork(LibraryShortcutArtwork.LibraryArtworkSlot.GAME_CARD) }, + onRemove = { onRemoveArtwork(LibraryShortcutArtwork.LibraryArtworkSlot.GAME_CARD) }, + ) + } + Box(Modifier.weight(1f)) { + RetroArtworkRow( + title = "Grid Image", + selected = state.artworkSelected[LibraryShortcutArtwork.LibraryArtworkSlot.GRID] == true, + onPick = { onPickArtwork(LibraryShortcutArtwork.LibraryArtworkSlot.GRID) }, + onRemove = { onRemoveArtwork(LibraryShortcutArtwork.LibraryArtworkSlot.GRID) }, + ) + } + } Spacer(Modifier.height(ItemGap)) - RetroArtworkRow( - title = "List Image", - selected = state.artworkSelected[LibraryShortcutArtwork.LibraryArtworkSlot.LIST] == true, - onPick = { onPickArtwork(LibraryShortcutArtwork.LibraryArtworkSlot.LIST) }, - onRemove = { onRemoveArtwork(LibraryShortcutArtwork.LibraryArtworkSlot.LIST) }, - ) + Row(horizontalArrangement = Arrangement.spacedBy(ItemGap)) { + Box(Modifier.weight(1f)) { + RetroArtworkRow( + title = "Carousel Image", + selected = state.artworkSelected[LibraryShortcutArtwork.LibraryArtworkSlot.CAROUSEL] == true, + onPick = { onPickArtwork(LibraryShortcutArtwork.LibraryArtworkSlot.CAROUSEL) }, + onRemove = { onRemoveArtwork(LibraryShortcutArtwork.LibraryArtworkSlot.CAROUSEL) }, + ) + } + Box(Modifier.weight(1f)) { + RetroArtworkRow( + title = "List Image", + selected = state.artworkSelected[LibraryShortcutArtwork.LibraryArtworkSlot.LIST] == true, + onPick = { onPickArtwork(LibraryShortcutArtwork.LibraryArtworkSlot.LIST) }, + onRemove = { onRemoveArtwork(LibraryShortcutArtwork.LibraryArtworkSlot.LIST) }, + ) + } + } } } } From f6b80810b1639a822529459fab8427366015dc75 Mon Sep 17 00:00:00 2001 From: max Date: Mon, 13 Jul 2026 12:18:48 -0400 Subject: [PATCH 07/38] Raise Start/Select pills further above the face buttons and align Menu to the same height --- app/src/main/feature/retro/RetroInputView.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/feature/retro/RetroInputView.kt b/app/src/main/feature/retro/RetroInputView.kt index 17969909e..55bca3107 100644 --- a/app/src/main/feature/retro/RetroInputView.kt +++ b/app/src/main/feature/retro/RetroInputView.kt @@ -184,7 +184,7 @@ class RetroInputView( val pillW = unit * 0.135f val pillH = unit * 0.062f val pillGap = unit * 0.02f - val pillY = clusterTop - pillH - unit * 0.025f + val pillY = clusterTop - pillH - unit * 0.065f val start = GlassButton(KeyEvent.KEYCODE_BUTTON_START, "START", GlassShape.PILL) start.bounds.set(width - margin - pillW, pillY, width - margin, pillY + pillH) buttons += start @@ -213,7 +213,7 @@ class RetroInputView( dpadRadius = unit * 0.155f dpadCx = margin + dpadRadius * 1.15f dpadCy = height - margin - dpadRadius * 1.15f - val menuY = max(dpadCy - dpadRadius - pillH - unit * 0.025f, leftCursor) + val menuY = max(pillY, leftCursor) menuButton.bounds.set(dpadCx - menuW * 0.5f, menuY, dpadCx + menuW * 0.5f, menuY + pillH) } } From 65a7df6a44f48fef0ee54cde0885acefa8c14e23 Mon Sep 17 00:00:00 2001 From: max Date: Mon, 13 Jul 2026 12:21:46 -0400 Subject: [PATCH 08/38] Make the retro drawer's first action a Pause/Resume toggle that pauses emulation --- app/src/main/feature/retro/RetroActivity.kt | 40 ++++++++++++++++++- app/src/main/feature/retro/RetroDrawerMenu.kt | 2 + 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/app/src/main/feature/retro/RetroActivity.kt b/app/src/main/feature/retro/RetroActivity.kt index 52c6cc027..edd8bc703 100644 --- a/app/src/main/feature/retro/RetroActivity.kt +++ b/app/src/main/feature/retro/RetroActivity.kt @@ -16,6 +16,7 @@ import androidx.core.view.WindowInsetsControllerCompat import androidx.lifecycle.lifecycleScope import com.swordfish.libretrodroid.GLRetroView import com.swordfish.libretrodroid.GLRetroViewData +import com.swordfish.libretrodroid.LibretroDroid import com.swordfish.libretrodroid.ShaderConfig import com.swordfish.libretrodroid.Variable import com.winlator.cmod.runtime.container.ContainerManager @@ -60,6 +61,21 @@ class RetroActivity : FixedFontScaleAppCompatActivity(), RetroInputView.Listener private var persistShortcut: Shortcut? = null private var playtimePrefs: SharedPreferences? = null private var sessionStart = 0L + private var emulationPaused = false + + private fun pauseEmulation() { + if (emulationPaused || !retroReady) return + emulationPaused = true + retroView.onPause() + LibretroDroid.pause() + } + + private fun resumeEmulation() { + if (!emulationPaused || !retroReady) return + emulationPaused = false + LibretroDroid.resume() + retroView.onResume() + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -315,7 +331,17 @@ class RetroActivity : FixedFontScaleAppCompatActivity(), RetroInputView.Listener private fun buildMainEntries(): List { val entries = mutableListOf() entries += - RetroMenuEntry.Action("Resume", RetroDrawerIcons.Resume) { menu.close() } + if (emulationPaused) { + RetroMenuEntry.Action("Resume", RetroDrawerIcons.Resume, active = true) { + resumeEmulation() + menu.close() + } + } else { + RetroMenuEntry.Action("Pause", RetroDrawerIcons.Pause) { + pauseEmulation() + menu.close() + } + } entries += RetroMenuEntry.Action("Save State", RetroDrawerIcons.Save) { menu.close() @@ -515,6 +541,18 @@ class RetroActivity : FixedFontScaleAppCompatActivity(), RetroInputView.Listener super.onPause() } + override fun onResume() { + super.onResume() + if (emulationPaused && retroReady) { + window.decorView.post { + if (emulationPaused && retroReady) { + retroView.onPause() + LibretroDroid.pause() + } + } + } + } + override fun onWindowFocusChanged(hasFocus: Boolean) { super.onWindowFocusChanged(hasFocus) if (hasFocus) hideSystemBars() diff --git a/app/src/main/feature/retro/RetroDrawerMenu.kt b/app/src/main/feature/retro/RetroDrawerMenu.kt index bbd9f7d34..87353631d 100644 --- a/app/src/main/feature/retro/RetroDrawerMenu.kt +++ b/app/src/main/feature/retro/RetroDrawerMenu.kt @@ -46,6 +46,7 @@ import androidx.compose.material.icons.outlined.Apps import androidx.compose.material.icons.outlined.Download import androidx.compose.material.icons.outlined.FastForward import androidx.compose.material.icons.outlined.Monitor +import androidx.compose.material.icons.outlined.Pause import androidx.compose.material.icons.outlined.PlayArrow import androidx.compose.material.icons.outlined.RestartAlt import androidx.compose.material.icons.outlined.Save @@ -947,6 +948,7 @@ object RetroDrawerTabs { object RetroDrawerIcons { val Resume = Icons.Outlined.PlayArrow + val Pause = Icons.Outlined.Pause val Save = Icons.Outlined.Save val Load = Icons.Outlined.Download val Reset = Icons.Outlined.RestartAlt From bc2b740fb56e916c67ff07ac8e78a25acacf3669 Mon Sep 17 00:00:00 2001 From: max Date: Mon, 13 Jul 2026 12:29:08 -0400 Subject: [PATCH 09/38] Let the ROM import dialog change the detected console - The Console row in Add Custom Game is now a dropdown listing every supported system, defaulting to the auto-detected one, so ambiguous extensions like .bin can be corrected to PS1 before importing - Detection itself is smarter for .bin: files over 16MB default to PS1 instead of Genesis since Genesis cartridges never reach that size --- app/src/main/app/shell/UnifiedActivity.kt | 86 ++++++++++++++++------- app/src/main/feature/retro/RetroSystem.kt | 7 ++ 2 files changed, 68 insertions(+), 25 deletions(-) diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index 625921198..36a9c4373 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -12225,7 +12225,7 @@ class UnifiedActivity : fun selectExecutable(path: String) { val extension = path.substringAfterLast('.', "").lowercase(java.util.Locale.US) - val detectedRetro = com.winlator.cmod.feature.retro.RetroSystems.fromExtension(extension) + val detectedRetro = com.winlator.cmod.feature.retro.RetroSystems.detectForFile(path) val launchable = path.endsWith(".exe", ignoreCase = true) || path.endsWith(".bat", ignoreCase = true) || @@ -12368,31 +12368,67 @@ class UnifiedActivity : val activeRetroSystem = retroSystem if (activeRetroSystem != null) { - Row( - modifier = - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(10.dp)) - .background(Color.White.copy(alpha = 0.05f)) - .padding(horizontal = 10.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - Icons.Outlined.SportsEsports, - contentDescription = null, - tint = StatusOnline.copy(alpha = 0.7f), - modifier = Modifier.size(14.dp), - ) - Spacer(Modifier.width(6.dp)) - Column(Modifier.weight(1f)) { - Text("Console", color = TextSecondary, fontSize = 9.sp) - Text( - activeRetroSystem.displayName, - color = TextPrimary, - fontSize = 10.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + var consoleMenuOpen by remember { mutableStateOf(false) } + Box { + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(Color.White.copy(alpha = 0.05f)) + .paneNavItem( + cornerRadius = 10.dp, + tapToSelect = true, + onActivate = { consoleMenuOpen = true }, + ).clickable { consoleMenuOpen = true } + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + Icons.Outlined.SportsEsports, + contentDescription = null, + tint = StatusOnline.copy(alpha = 0.7f), + modifier = Modifier.size(14.dp), ) + Spacer(Modifier.width(6.dp)) + Column(Modifier.weight(1f)) { + Text("Console", color = TextSecondary, fontSize = 9.sp) + Text( + activeRetroSystem.displayName, + color = TextPrimary, + fontSize = 10.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Icon( + Icons.Outlined.Edit, + contentDescription = stringResource(R.string.common_ui_change), + tint = Accent, + modifier = Modifier.size(14.dp), + ) + } + DropdownMenu( + expanded = consoleMenuOpen, + onDismissRequest = { consoleMenuOpen = false }, + containerColor = Color(0xFF1C232E), + ) { + com.winlator.cmod.feature.retro.RetroSystems.ALL.forEach { candidate -> + DropdownMenuItem( + text = { + Text( + candidate.displayName, + color = + if (candidate.id == activeRetroSystem.id) Accent else TextPrimary, + fontSize = 12.sp, + ) + }, + onClick = { + retroSystem = candidate + consoleMenuOpen = false + }, + ) + } } } } else { diff --git a/app/src/main/feature/retro/RetroSystem.kt b/app/src/main/feature/retro/RetroSystem.kt index 5d3bb284e..cb8026576 100644 --- a/app/src/main/feature/retro/RetroSystem.kt +++ b/app/src/main/feature/retro/RetroSystem.kt @@ -123,6 +123,13 @@ object RetroSystems { return fromExtension(ext) } + fun detectForFile(path: String): RetroSystem? { + val ext = path.substringAfterLast('.', "").lowercase(Locale.US) + val detected = fromExtension(ext) ?: return null + if (ext == "bin" && java.io.File(path).length() > 16L * 1024 * 1024) return PSX + return detected + } + fun isRetroRom(path: String?): Boolean { if (path.isNullOrBlank()) return false val ext = path.substringAfterLast('.', "").lowercase(Locale.US) From 8bccdfae2c398aeaaf9dd74da90ed8af7b36c011 Mon Sep 17 00:00:00 2001 From: max Date: Mon, 13 Jul 2026 12:48:48 -0400 Subject: [PATCH 10/38] Match retro overlay button sizes and stroke weight to the PC GameHub metrics - All overlay geometry now derives from the PC formula snap = screenWidth/100 with the GameHub profile scales: face buttons 3*snap radius, d-pad 7*snap*1.07, stick 6*snap*1.17, triggers 8x4*snap*1.3, Start/Select pills 8x4*snap*0.75 - Stroke width now max(2, 0.18*snap) exactly like the PC glass renderer, fixing rims that drew about four times too thick - Labels use the PC text rule: fit-to-width capped at 2*snap*profile scale - Touch hit targets inflate by a snap-based margin so the smaller visuals keep comfortable press areas --- app/src/main/feature/retro/RetroInputView.kt | 82 +++++++++++--------- 1 file changed, 46 insertions(+), 36 deletions(-) diff --git a/app/src/main/feature/retro/RetroInputView.kt b/app/src/main/feature/retro/RetroInputView.kt index 55bca3107..bc014c800 100644 --- a/app/src/main/feature/retro/RetroInputView.kt +++ b/app/src/main/feature/retro/RetroInputView.kt @@ -46,6 +46,7 @@ class RetroInputView( val keyCode: Int, val label: String, val shape: GlassShape, + val textScale: Float = 1f, val bounds: RectF = RectF(), ) @@ -78,7 +79,8 @@ class RetroInputView( } private val buttons = mutableListOf() - private val menuButton = GlassButton(0, "MENU", GlassShape.PILL) + private val menuButton = GlassButton(0, "MENU", GlassShape.PILL, textScale = 0.75f) + private var snap = 0f private var dpadCx = 0f private var dpadCy = 0f @@ -123,42 +125,49 @@ class RetroInputView( buttons.clear() val width = w.toFloat() val height = h.toFloat() - val unit = min(width, height) - val margin = unit * 0.05f - val faceRadius = unit * 0.085f - strokeWidth = max(2f, faceRadius * 0.16f) + snap = width / 100f + val margin = snap * 2.5f + val faceRadius = snap * 3f + strokeWidth = max(2f, snap * 0.18f) - val trigW = unit * 0.30f - val trigH = unit * 0.105f - val trigGap = unit * 0.02f + val trigW = snap * 10.4f + val trigH = snap * 5.2f + val trigGap = snap * 1.5f var leftCursor = margin var rightCursor = margin if (config.hasTriggers) { - val lt = GlassButton(KeyEvent.KEYCODE_BUTTON_L2, config.leftTriggerLabel, GlassShape.TRIGGER_LT) + val lt = + GlassButton(KeyEvent.KEYCODE_BUTTON_L2, config.leftTriggerLabel, GlassShape.TRIGGER_LT, textScale = 1.3f) lt.bounds.set(margin, leftCursor, margin + trigW, leftCursor + trigH) buttons += lt leftCursor += trigH + trigGap if (config.showRightTrigger) { - val rt = GlassButton(KeyEvent.KEYCODE_BUTTON_R2, config.rightTriggerLabel, GlassShape.TRIGGER_RT) + val rt = + GlassButton( + KeyEvent.KEYCODE_BUTTON_R2, + config.rightTriggerLabel, + GlassShape.TRIGGER_RT, + textScale = 1.3f, + ) rt.bounds.set(width - margin - trigW, rightCursor, width - margin, rightCursor + trigH) buttons += rt rightCursor += trigH + trigGap } } if (config.hasShoulders) { - val lb = GlassButton(KeyEvent.KEYCODE_BUTTON_L1, "L", GlassShape.TRIGGER_LB) + val lb = GlassButton(KeyEvent.KEYCODE_BUTTON_L1, "L", GlassShape.TRIGGER_LB, textScale = 1.3f) lb.bounds.set(margin, leftCursor, margin + trigW, leftCursor + trigH) buttons += lb - val rb = GlassButton(KeyEvent.KEYCODE_BUTTON_R1, "R", GlassShape.TRIGGER_RB) + val rb = GlassButton(KeyEvent.KEYCODE_BUTTON_R1, "R", GlassShape.TRIGGER_RB, textScale = 1.3f) rb.bounds.set(width - margin - trigW, rightCursor, width - margin, rightCursor + trigH) buttons += rb leftCursor += trigH + trigGap } - val clusterCx = width - margin - faceRadius * 2.6f - val clusterCy = height - margin - faceRadius * 2.6f - val spread = faceRadius * 1.75f + val spread = snap * 5.5f + val clusterCx = width - margin - faceRadius - spread + val clusterCy = height - snap * 6f - faceRadius - spread var clusterTop = height fun addFace( keyCode: Int, @@ -177,18 +186,18 @@ class RetroInputView( addFace(KeyEvent.KEYCODE_BUTTON_Y, "Y", clusterCx - spread, clusterCy) addFace(KeyEvent.KEYCODE_BUTTON_A, "A", clusterCx + spread, clusterCy) } else { - addFace(KeyEvent.KEYCODE_BUTTON_B, "B", clusterCx - faceRadius * 1.1f, clusterCy + faceRadius * 0.7f) - addFace(KeyEvent.KEYCODE_BUTTON_A, "A", clusterCx + faceRadius * 1.1f, clusterCy - faceRadius * 0.7f) + addFace(KeyEvent.KEYCODE_BUTTON_B, "B", clusterCx - faceRadius * 1.1f, clusterCy + spread * 0.5f + faceRadius * 0.5f) + addFace(KeyEvent.KEYCODE_BUTTON_A, "A", clusterCx + faceRadius * 1.1f, clusterCy + spread * 0.5f - faceRadius * 1.1f) } - val pillW = unit * 0.135f - val pillH = unit * 0.062f - val pillGap = unit * 0.02f - val pillY = clusterTop - pillH - unit * 0.065f - val start = GlassButton(KeyEvent.KEYCODE_BUTTON_START, "START", GlassShape.PILL) + val pillW = snap * 6f + val pillH = snap * 3f + val pillGap = snap * 1.2f + val pillY = clusterTop - pillH - snap * 3.5f + val start = GlassButton(KeyEvent.KEYCODE_BUTTON_START, "START", GlassShape.PILL, textScale = 0.75f) start.bounds.set(width - margin - pillW, pillY, width - margin, pillY + pillH) buttons += start - val select = GlassButton(KeyEvent.KEYCODE_BUTTON_SELECT, "SELECT", GlassShape.PILL) + val select = GlassButton(KeyEvent.KEYCODE_BUTTON_SELECT, "SELECT", GlassShape.PILL, textScale = 0.75f) select.bounds.set( width - margin - pillW * 2 - pillGap, pillY, @@ -197,22 +206,22 @@ class RetroInputView( ) buttons += select - val menuW = unit * 0.15f + val menuW = snap * 6f if (config.hasStick) { - dpadRadius = unit * 0.135f - dpadCx = margin + dpadRadius + unit * 0.02f + dpadRadius = snap * 6.5f + dpadCx = margin + dpadRadius + snap * 1f menuButton.bounds.set(dpadCx - menuW * 0.5f, leftCursor, dpadCx + menuW * 0.5f, leftCursor + pillH) leftCursor += pillH + trigGap dpadCy = leftCursor + dpadRadius leftCursor += dpadRadius * 2 + trigGap - stickRadius = unit * 0.115f + stickRadius = snap * 7f stickCx = dpadCx - stickCy = max(leftCursor + stickRadius, height - margin - stickRadius) + stickCy = max(leftCursor + stickRadius, height - snap * 6f - stickRadius) } else { stickRadius = 0f - dpadRadius = unit * 0.155f - dpadCx = margin + dpadRadius * 1.15f - dpadCy = height - margin - dpadRadius * 1.15f + dpadRadius = snap * 7.5f + dpadCx = margin + dpadRadius + dpadCy = height - snap * 6f - dpadRadius val menuY = max(pillY, leftCursor) menuButton.bounds.set(dpadCx - menuW * 0.5f, menuY, dpadCx + menuW * 0.5f, menuY + pillH) } @@ -289,8 +298,8 @@ class RetroInputView( paint.color = textColor paint.textAlign = Paint.Align.CENTER paint.isFakeBoldText = true - val maxTextWidth = b.width() - strokeWidth * 3 - paint.textSize = b.height() * if (button.label.length > 2) 0.42f else 0.62f + val maxTextWidth = b.width() - strokeWidth * 2 + paint.textSize = snap * 2f * button.textScale if (button.label.isNotEmpty() && paint.measureText(button.label) > maxTextWidth) { paint.textSize = paint.textSize * maxTextWidth / paint.measureText(button.label) } @@ -407,12 +416,13 @@ class RetroInputView( y: Float, ): Boolean { val b = button.bounds + val inflate = snap * 1.2f return if (button.shape == GlassShape.CIRCLE) { - val r = b.width() * 0.5f * 1.25f + val r = b.width() * 0.5f + inflate hypot(x - b.centerX(), y - b.centerY()) <= r } else { - x >= b.left - b.height() * 0.2f && x <= b.right + b.height() * 0.2f && - y >= b.top - b.height() * 0.25f && y <= b.bottom + b.height() * 0.25f + x >= b.left - inflate && x <= b.right + inflate && + y >= b.top - inflate && y <= b.bottom + inflate } } From 96cfa2a677edaad9217a46d2f39805ab19aff8b0 Mon Sep 17 00:00:00 2001 From: max Date: Mon, 13 Jul 2026 15:08:09 -0400 Subject: [PATCH 11/38] Improve physical controller support for retro games - Auto-hide the on-screen glass controls whenever a real game controller is connected and bring them back on disconnect, using the shared ExternalController device filter and an InputManager listener - Left analog stick now drives d-pad movement on consoles without a stick (NES, SNES, GB/GBC/GBA, Genesis, PS1) with a proper deadzone, while N64 keeps true analog; hat/d-pad input still works everywhere - Face buttons stay positionally mapped across Xbox, PlayStation, and standard generic pads: the south button acts as Nintendo B and east as Nintendo A (PS5 Cross = B, Circle = A), matching RetroPad layout --- app/src/main/feature/retro/RetroActivity.kt | 81 +++++++++++++++++---- 1 file changed, 67 insertions(+), 14 deletions(-) diff --git a/app/src/main/feature/retro/RetroActivity.kt b/app/src/main/feature/retro/RetroActivity.kt index edd8bc703..981b236b4 100644 --- a/app/src/main/feature/retro/RetroActivity.kt +++ b/app/src/main/feature/retro/RetroActivity.kt @@ -1,6 +1,7 @@ package com.winlator.cmod.feature.retro import android.content.SharedPreferences +import android.hardware.input.InputManager import android.os.Bundle import android.view.InputDevice import android.view.KeyEvent @@ -21,6 +22,7 @@ import com.swordfish.libretrodroid.ShaderConfig import com.swordfish.libretrodroid.Variable import com.winlator.cmod.runtime.container.ContainerManager import com.winlator.cmod.runtime.container.Shortcut +import com.winlator.cmod.runtime.input.controls.ExternalController import com.winlator.cmod.shared.android.FixedFontScaleAppCompatActivity import com.winlator.cmod.shared.theme.WinNativeTheme import kotlinx.coroutines.Dispatchers @@ -28,6 +30,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import java.io.File +import kotlin.math.abs class RetroActivity : FixedFontScaleAppCompatActivity(), RetroInputView.Listener { companion object { @@ -62,6 +65,35 @@ class RetroActivity : FixedFontScaleAppCompatActivity(), RetroInputView.Listener private var playtimePrefs: SharedPreferences? = null private var sessionStart = 0L private var emulationPaused = false + private var controllerConnected = false + private var inputManager: InputManager? = null + + private val inputDeviceListener = + object : InputManager.InputDeviceListener { + override fun onInputDeviceAdded(deviceId: Int) = refreshControllerPresence() + + override fun onInputDeviceRemoved(deviceId: Int) = refreshControllerPresence() + + override fun onInputDeviceChanged(deviceId: Int) = refreshControllerPresence() + } + + private val stickIsAnalog: Boolean + get() = system?.id == RetroSystems.N64.id + + private fun anyGameControllerConnected(): Boolean = + InputDevice.getDeviceIds().any { id -> + ExternalController.isGameController(InputDevice.getDevice(id)) + } + + private fun refreshControllerPresence() { + controllerConnected = anyGameControllerConnected() + updateOverlayVisibility() + } + + private fun updateOverlayVisibility() { + overlay?.visibility = + if (touchControlsSetting && !controllerConnected) View.VISIBLE else View.GONE + } private fun pauseEmulation() { if (emulationPaused || !retroReady) return @@ -152,7 +184,6 @@ class RetroActivity : FixedFontScaleAppCompatActivity(), RetroInputView.Listener ) val inputView = RetroInputView(this, this, resolvedSystem) - inputView.visibility = if (touchControlsSetting) View.VISIBLE else View.GONE overlay = inputView root.addView( inputView, @@ -183,11 +214,19 @@ class RetroActivity : FixedFontScaleAppCompatActivity(), RetroInputView.Listener setContentView(root) retroReady = true + inputManager = getSystemService(InputManager::class.java) + inputManager?.registerInputDeviceListener(inputDeviceListener, null) + refreshControllerPresence() recordLaunchStats() observeErrors() observeEvents() } + override fun onDestroy() { + inputManager?.unregisterInputDeviceListener(inputDeviceListener) + super.onDestroy() + } + private fun recordLaunchStats() { val prefs = getSharedPreferences("playtime_stats", MODE_PRIVATE) playtimePrefs = prefs @@ -321,7 +360,7 @@ class RetroActivity : FixedFontScaleAppCompatActivity(), RetroInputView.Listener listOf( RetroMenuEntry.Toggle("On-screen Controls", checked = touchControlsSetting) { value -> touchControlsSetting = value - overlay?.visibility = if (value) View.VISIBLE else View.GONE + updateOverlayVisibility() persistExtra(RetroShortcuts.KEY_TOUCH_CONTROLS, if (value) "1" else "0") menu.rebuild() }, @@ -452,18 +491,32 @@ class RetroActivity : FixedFontScaleAppCompatActivity(), RetroInputView.Listener event.action == MotionEvent.ACTION_MOVE && event.source and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK ) { - retroView.sendMotionEvent( - GLRetroView.MOTION_SOURCE_DPAD, - event.getAxisValue(MotionEvent.AXIS_HAT_X), - event.getAxisValue(MotionEvent.AXIS_HAT_Y), - 0, - ) - retroView.sendMotionEvent( - GLRetroView.MOTION_SOURCE_ANALOG_LEFT, - event.getAxisValue(MotionEvent.AXIS_X), - event.getAxisValue(MotionEvent.AXIS_Y), - 0, - ) + val hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X) + val hatY = event.getAxisValue(MotionEvent.AXIS_HAT_Y) + val stickX = event.getAxisValue(MotionEvent.AXIS_X) + val stickY = event.getAxisValue(MotionEvent.AXIS_Y) + if (stickIsAnalog) { + retroView.sendMotionEvent(GLRetroView.MOTION_SOURCE_DPAD, hatX, hatY, 0) + retroView.sendMotionEvent(GLRetroView.MOTION_SOURCE_ANALOG_LEFT, stickX, stickY, 0) + } else { + val deadzone = 0.45f + val dpadX = + when { + abs(hatX) > 0.5f -> hatX + stickX > deadzone -> 1f + stickX < -deadzone -> -1f + else -> 0f + } + val dpadY = + when { + abs(hatY) > 0.5f -> hatY + stickY > deadzone -> 1f + stickY < -deadzone -> -1f + else -> 0f + } + retroView.sendMotionEvent(GLRetroView.MOTION_SOURCE_DPAD, dpadX, dpadY, 0) + retroView.sendMotionEvent(GLRetroView.MOTION_SOURCE_ANALOG_LEFT, stickX, stickY, 0) + } retroView.sendMotionEvent( GLRetroView.MOTION_SOURCE_ANALOG_RIGHT, event.getAxisValue(MotionEvent.AXIS_Z), From 680b75caa2f75083b6251258d72935a2f4ad0f0b Mon Sep 17 00:00:00 2001 From: max Date: Mon, 13 Jul 2026 15:40:23 -0400 Subject: [PATCH 12/38] Align the retro in-game drawer with the PC xservermenu and add a retro HUD - Pause now sits bottom-left and Exit bottom-right as a pinned bottom action row styled exactly like the PC drawer's pause/exit buttons, with left/right controller navigation across them - Menu action cards (Save/Load State, Reset, Fast Forward, HUD, Disc) now square off by filling the pane height with the PC ActionCardGrid row-height formula instead of a fixed card height - New performance HUD for retro games, fully separate from the PC HUD but visually identical: same gauges (FPS, GPU, CPU, RAM, frametime, watts and temperature) rendered over the left half of the screen, with the console name (NES, GB, PS1, ...) shown where PC games display DXVK/VKD3D - Stats come from the same sources the PC HUD uses: per-app /proc sampling via CPUStatus, kgsl/mali sysfs GPU load, ActivityManager memory, and BatteryManager wattage and temperature; FPS and frametime derive from the emulator's rendered-frame events - HUD toggles from a new drawer card, persists per game, and is gated entirely inside the retro backend so the PC HUD path is untouched --- app/src/main/feature/retro/RetroActivity.kt | 86 +++- app/src/main/feature/retro/RetroDrawerMenu.kt | 221 ++++++---- .../main/feature/retro/RetroPerformanceHud.kt | 382 ++++++++++++++++++ app/src/main/feature/retro/RetroShortcuts.kt | 2 + 4 files changed, 592 insertions(+), 99 deletions(-) create mode 100644 app/src/main/feature/retro/RetroPerformanceHud.kt diff --git a/app/src/main/feature/retro/RetroActivity.kt b/app/src/main/feature/retro/RetroActivity.kt index 981b236b4..e40726848 100644 --- a/app/src/main/feature/retro/RetroActivity.kt +++ b/app/src/main/feature/retro/RetroActivity.kt @@ -10,6 +10,13 @@ import android.view.View import android.view.WindowManager import android.widget.FrameLayout import android.widget.Toast +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.ComposeView import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat @@ -42,6 +49,7 @@ class RetroActivity : FixedFontScaleAppCompatActivity(), RetroInputView.Listener const val EXTRA_SHADER = "retro_shader" const val EXTRA_TOUCH_CONTROLS = "retro_touch_controls" const val EXTRA_AUDIO = "retro_audio" + const val EXTRA_HUD = "retro_hud" const val EXTRA_VARIABLES = "retro_variables" private val SHADER_KEYS = listOf("default", "crt", "lcd", "sharp") @@ -67,6 +75,8 @@ class RetroActivity : FixedFontScaleAppCompatActivity(), RetroInputView.Listener private var emulationPaused = false private var controllerConnected = false private var inputManager: InputManager? = null + private var hudVisible = false + private var statsCollector: RetroStatsCollector? = null private val inputDeviceListener = object : InputManager.InputDeviceListener { @@ -194,12 +204,33 @@ class RetroActivity : FixedFontScaleAppCompatActivity(), RetroInputView.Listener ) menu.entriesProvider = { pane -> buildEntriesFor(pane) } + menu.bottomProvider = { buildBottomEntries() } menu.tabs = RetroDrawerTabs.build(resolvedSystem, RetroCoreOptions.forSystem(resolvedSystem).isNotEmpty()) - menu.onExit = { finish() } + hudVisible = intent.getBooleanExtra(EXTRA_HUD, false) + statsCollector = RetroStatsCollector(this, resolvedSystem.shortName) + RetroPerformanceHudState.setVisible(hudVisible) val menuView = ComposeView(this).apply { setContent { WinNativeTheme { + val hudOn by RetroPerformanceHudState.visible.collectAsState() + if (hudOn) { + Box( + modifier = + Modifier + .fillMaxHeight() + .fillMaxWidth(0.5f) + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + awaitPointerEvent().changes.forEach { it.consume() } + } + } + }, + ) { + RetroPerformanceHudOverlay() + } + } RetroDrawerMenu(menu) } } @@ -217,6 +248,7 @@ class RetroActivity : FixedFontScaleAppCompatActivity(), RetroInputView.Listener inputManager = getSystemService(InputManager::class.java) inputManager?.registerInputDeviceListener(inputDeviceListener, null) refreshControllerPresence() + statsCollector?.start(lifecycleScope) recordLaunchStats() observeErrors() observeEvents() @@ -224,6 +256,8 @@ class RetroActivity : FixedFontScaleAppCompatActivity(), RetroInputView.Listener override fun onDestroy() { inputManager?.unregisterInputDeviceListener(inputDeviceListener) + statsCollector?.stop() + RetroPerformanceHudState.setVisible(false) super.onDestroy() } @@ -255,12 +289,15 @@ class RetroActivity : FixedFontScaleAppCompatActivity(), RetroInputView.Listener retroView .getGLRetroEvents() .onEach { event -> - if (event is GLRetroView.GLRetroEvents.SurfaceCreated) { - if (!audioEnabledSetting) retroView.audioEnabled = false - lifecycleScope.launch(Dispatchers.Default) { - runCatching { - diskCount = retroView.getAvailableDisks() - currentDisk = retroView.getCurrentDisk() + when (event) { + is GLRetroView.GLRetroEvents.FrameRendered -> statsCollector?.onFrame() + is GLRetroView.GLRetroEvents.SurfaceCreated -> { + if (!audioEnabledSetting) retroView.audioEnabled = false + lifecycleScope.launch(Dispatchers.Default) { + runCatching { + diskCount = retroView.getAvailableDisks() + currentDisk = retroView.getCurrentDisk() + } } } } @@ -369,18 +406,6 @@ class RetroActivity : FixedFontScaleAppCompatActivity(), RetroInputView.Listener private fun buildMainEntries(): List { val entries = mutableListOf() - entries += - if (emulationPaused) { - RetroMenuEntry.Action("Resume", RetroDrawerIcons.Resume, active = true) { - resumeEmulation() - menu.close() - } - } else { - RetroMenuEntry.Action("Pause", RetroDrawerIcons.Pause) { - pauseEmulation() - menu.close() - } - } entries += RetroMenuEntry.Action("Save State", RetroDrawerIcons.Save) { menu.close() @@ -402,6 +427,13 @@ class RetroActivity : FixedFontScaleAppCompatActivity(), RetroInputView.Listener retroView.frameSpeed = if (fastForward) 2 else 1 menu.rebuild() } + entries += + RetroMenuEntry.Action("HUD", RetroDrawerIcons.Hud, active = hudVisible) { + hudVisible = !hudVisible + RetroPerformanceHudState.setVisible(hudVisible) + persistExtra(RetroShortcuts.KEY_HUD, if (hudVisible) "1" else "0") + menu.rebuild() + } if (diskCount > 1) { entries += RetroMenuEntry.Action("Disc ${currentDisk + 1}/$diskCount", RetroDrawerIcons.Disc) { @@ -416,6 +448,22 @@ class RetroActivity : FixedFontScaleAppCompatActivity(), RetroInputView.Listener return entries } + private fun buildBottomEntries(): List = + listOf( + if (emulationPaused) { + RetroMenuEntry.Action("Resume", RetroDrawerIcons.Resume, active = true) { + resumeEmulation() + menu.close() + } + } else { + RetroMenuEntry.Action("Pause", RetroDrawerIcons.Pause) { + pauseEmulation() + menu.close() + } + }, + RetroMenuEntry.Action("Exit", RetroDrawerIcons.Exit, danger = true) { finish() }, + ) + private fun openMenu() { if (!retroReady) { finish() diff --git a/app/src/main/feature/retro/RetroDrawerMenu.kt b/app/src/main/feature/retro/RetroDrawerMenu.kt index 87353631d..cc17a89fc 100644 --- a/app/src/main/feature/retro/RetroDrawerMenu.kt +++ b/app/src/main/feature/retro/RetroDrawerMenu.kt @@ -50,6 +50,7 @@ import androidx.compose.material.icons.outlined.Pause import androidx.compose.material.icons.outlined.PlayArrow import androidx.compose.material.icons.outlined.RestartAlt import androidx.compose.material.icons.outlined.Save +import androidx.compose.material.icons.outlined.Speed import androidx.compose.material.icons.outlined.SportsEsports import androidx.compose.material.icons.outlined.Tune import androidx.compose.material3.Icon @@ -159,12 +160,15 @@ class RetroMenuController { var region by mutableIntStateOf(1) var railIndex by mutableIntStateOf(0) var contentIndex by mutableIntStateOf(0) + var bottomIndex by mutableIntStateOf(0) var controllerActive by mutableStateOf(false) var tabs by mutableStateOf>(emptyList()) var entries by mutableStateOf>(emptyList()) private set + var bottomEntries by mutableStateOf>(emptyList()) + private set var entriesProvider: ((RetroPane?) -> List)? = null - var onExit: (() -> Unit)? = null + var bottomProvider: (() -> List)? = null val gridColumns: Int get() = if (pane == null) 3 else 1 @@ -173,6 +177,7 @@ class RetroMenuController { railIndex = 0 region = 1 contentIndex = 0 + bottomIndex = 0 controllerActive = false rebuild() visible = true @@ -192,7 +197,9 @@ class RetroMenuController { fun rebuild() { entries = entriesProvider?.invoke(pane) ?: emptyList() + bottomEntries = bottomProvider?.invoke() ?: emptyList() if (contentIndex >= entries.size) contentIndex = (entries.size - 1).coerceAtLeast(0) + if (bottomIndex >= bottomEntries.size) bottomIndex = (bottomEntries.size - 1).coerceAtLeast(0) } private fun activate(direction: Int) { @@ -251,17 +258,23 @@ class RetroMenuController { when (region) { 0 -> railIndex = (railIndex - 1 + tabs.size) % tabs.size 1 -> if (pane == null) moveContent(-1) else activate(-1) + 2 -> if (bottomEntries.isNotEmpty()) { + bottomIndex = (bottomIndex - 1 + bottomEntries.size) % bottomEntries.size + } } KeyEvent.KEYCODE_DPAD_RIGHT -> when (region) { 0 -> railIndex = (railIndex + 1) % tabs.size 1 -> if (pane == null) moveContent(1) else activate(1) + 2 -> if (bottomEntries.isNotEmpty()) { + bottomIndex = (bottomIndex + 1) % bottomEntries.size + } } KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_BUTTON_A -> when (region) { 0 -> tabs.getOrNull(railIndex)?.let { showPane(it.pane) } 1 -> activate(0) - 2 -> onExit?.invoke() + 2 -> bottomEntries.getOrNull(bottomIndex)?.onClick?.invoke() } } } else if (action == KeyEvent.ACTION_UP) { @@ -330,10 +343,10 @@ fun RetroDrawerMenu(controller: RetroMenuController) { } } } - AnimatedVisibility(visible = controller.pane == null) { + AnimatedVisibility(visible = controller.pane == null && controller.bottomEntries.isNotEmpty()) { Column { ThinDivider() - RetroBottomExit(controller, paneScale) + RetroBottomActions(controller, paneScale) } } } @@ -527,39 +540,47 @@ private fun RetroActionGrid( paneScale: Float, ) { val actions = controller.entries - Column( - modifier = - Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = (10f * paneScale).dp, vertical = (10f * paneScale).dp), - verticalArrangement = Arrangement.spacedBy((8f * paneScale).dp), - ) { - actions.chunked(controller.gridColumns).forEachIndexed { rowIndex, row -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy((8f * paneScale).dp), - ) { - row.forEachIndexed { colIndex, entry -> - val flatIndex = rowIndex * controller.gridColumns + colIndex - if (entry is RetroMenuEntry.Action) { - RetroActionCard( - entry = entry, - highlighted = - controller.controllerActive && - controller.region == 1 && - controller.contentIndex == flatIndex, - paneScale = paneScale, - modifier = Modifier.weight(1f).height((64f * paneScale).dp), - onClick = { - controller.contentIndex = flatIndex - entry.onClick() - }, - ) + val spacing = (8f * paneScale).dp + val verticalPadding = (10f * paneScale).dp + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val rows = ((actions.size + controller.gridColumns - 1) / controller.gridColumns).coerceAtLeast(1) + val rowHeight = + ((maxHeight - verticalPadding * 2 - spacing * (rows - 1)) / rows) + .coerceAtLeast((72f * paneScale).dp) + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = (10f * paneScale).dp, vertical = verticalPadding), + verticalArrangement = Arrangement.spacedBy(spacing), + ) { + actions.chunked(controller.gridColumns).forEachIndexed { rowIndex, row -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing), + ) { + row.forEachIndexed { colIndex, entry -> + val flatIndex = rowIndex * controller.gridColumns + colIndex + if (entry is RetroMenuEntry.Action) { + RetroActionCard( + entry = entry, + highlighted = + controller.controllerActive && + controller.region == 1 && + controller.contentIndex == flatIndex, + paneScale = paneScale, + modifier = Modifier.weight(1f).height(rowHeight), + onClick = { + controller.contentIndex = flatIndex + entry.onClick() + }, + ) + } + } + repeat(controller.gridColumns - row.size) { + Spacer(Modifier.weight(1f)) } - } - repeat(controller.gridColumns - row.size) { - Spacer(Modifier.weight(1f)) } } } @@ -854,67 +875,105 @@ private fun RetroRadioRow( } @Composable -private fun RetroBottomExit( +private fun RetroBottomActions( controller: RetroMenuController, paneScale: Float, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = (10f * paneScale).dp, vertical = (8f * paneScale).dp), + horizontalArrangement = Arrangement.spacedBy((8f * paneScale).dp), + verticalAlignment = Alignment.CenterVertically, + ) { + controller.bottomEntries.forEachIndexed { index, entry -> + RetroBottomActionButton( + entry = entry, + highlighted = + controller.controllerActive && + controller.region == 2 && + controller.bottomIndex == index, + paneScale = paneScale, + modifier = Modifier.weight(1f), + onClick = { + controller.bottomIndex = index + entry.onClick() + }, + ) + } + } +} + +@Composable +private fun RetroBottomActionButton( + entry: RetroMenuEntry.Action, + highlighted: Boolean, + paneScale: Float, + modifier: Modifier = Modifier, + onClick: () -> Unit, ) { val interactionSource = remember { MutableInteractionSource() } val pressed = interactionSource.collectIsPressedAsState().value - val highlighted = controller.controllerActive && controller.region == 2 val bgColor by animateColorAsState( targetValue = when { highlighted -> DrawerFocusFill - pressed -> TileExitPressed - else -> TileExitResting + entry.danger && pressed -> TileExitPressed + entry.danger -> TileExitResting + pressed -> PaneSurfacePressed + else -> PaneInnerResting }, animationSpec = tween(120), - label = "retroExitBg", + label = "retroBottomBg", ) + val borderColor = + when { + entry.danger -> GlassExitTint.copy(alpha = 0.34f) + entry.active -> ActiveCardBorder + else -> RestingCardBorder + } + val tint = + when { + entry.danger -> GlassExitTint + entry.active -> DrawerActiveAccent + else -> DrawerTextPrimary + } val cornerRadius = (14f * paneScale).dp val shape = RoundedCornerShape(cornerRadius) Row( modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = (10f * paneScale).dp, vertical = (8f * paneScale).dp), + modifier + .clip(shape) + .background(bgColor) + .border(1.dp, borderColor, shape) + .then( + if (highlighted) { + Modifier.chasingBorder(cornerRadius = cornerRadius, borderWidth = 1.5.dp, animationDurationMs = 8200) + } else { + Modifier + }, + ) + .clickable(interactionSource = interactionSource, indication = null, onClick = onClick) + .padding(horizontal = (12f * paneScale).dp, vertical = (10f * paneScale).dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, ) { - Row( - modifier = - Modifier - .weight(1f) - .clip(shape) - .background(bgColor) - .border(1.dp, GlassExitTint.copy(alpha = 0.34f), shape) - .then( - if (highlighted) { - Modifier.chasingBorder(cornerRadius = cornerRadius, borderWidth = 1.5.dp, animationDurationMs = 8200) - } else { - Modifier - }, - ) - .clickable(interactionSource = interactionSource, indication = null) { - controller.onExit?.invoke() - } - .padding(horizontal = (12f * paneScale).dp, vertical = (10f * paneScale).dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, - ) { - Icon( - imageVector = Icons.AutoMirrored.Outlined.ExitToApp, - contentDescription = null, - tint = GlassExitTint, - modifier = Modifier.size((18f * paneScale).dp), - ) - Spacer(Modifier.width((8f * paneScale).dp)) - Text( - text = "Exit", - color = GlassExitTint, - fontSize = (13f * paneScale).sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - ) - } + Icon( + imageVector = entry.icon, + contentDescription = entry.label, + tint = tint, + modifier = Modifier.size((18f * paneScale).dp), + ) + Spacer(Modifier.width((8f * paneScale).dp)) + Text( + text = entry.label, + color = tint, + fontSize = (13f * paneScale).sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) } } @@ -954,4 +1013,6 @@ object RetroDrawerIcons { val Reset = Icons.Outlined.RestartAlt val FastForward = Icons.Outlined.FastForward val Disc = Icons.Outlined.Album + val Hud = Icons.Outlined.Speed + val Exit = Icons.AutoMirrored.Outlined.ExitToApp } diff --git a/app/src/main/feature/retro/RetroPerformanceHud.kt b/app/src/main/feature/retro/RetroPerformanceHud.kt new file mode 100644 index 000000000..324ef7cf4 --- /dev/null +++ b/app/src/main/feature/retro/RetroPerformanceHud.kt @@ -0,0 +1,382 @@ +package com.winlator.cmod.feature.retro + +import android.app.Activity +import android.app.ActivityManager +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.BatteryManager +import android.os.SystemClock +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.winlator.cmod.runtime.system.CPUStatus +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.io.BufferedReader +import java.io.File +import java.io.FileReader +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.atomic.AtomicLong + +object RetroPerformanceHudState { + data class Snapshot( + val fps: Float = 0f, + val frametimeMs: Float = 0f, + val gpuLoad: Int = -1, + val cpuPercent: Int = -1, + val ramPercent: Int = -1, + val batteryWatts: Float = -1f, + val tempC: Int = -1, + val console: String = "", + ) + + private val _state = MutableStateFlow(Snapshot()) + val state: StateFlow = _state.asStateFlow() + private val _visible = MutableStateFlow(false) + val visible: StateFlow = _visible.asStateFlow() + + fun setVisible(v: Boolean) { + _visible.value = v + } + + fun updateValues(snapshot: Snapshot) { + _state.value = snapshot + } +} + +class RetroStatsCollector( + private val activity: Activity, + private val console: String, +) { + private val frameCount = AtomicInteger(0) + private val lastFrameNano = AtomicLong(0L) + private var prevCpuSample: CPUStatus.AppCpuSample? = null + private var lastGoodGpu = -1 + private var lastGoodGpuTime = 0L + private var job: Job? = null + + fun onFrame() { + frameCount.incrementAndGet() + lastFrameNano.set(System.nanoTime()) + } + + fun start(scope: CoroutineScope) { + if (job != null) return + job = + scope.launch(Dispatchers.Default) { + var lastTick = System.nanoTime() + while (isActive) { + delay(1000) + val now = System.nanoTime() + val frames = frameCount.getAndSet(0) + val dtSec = (now - lastTick) / 1e9f + lastTick = now + val stalled = now - lastFrameNano.get() > 1_500_000_000L + val fps = if (stalled || dtSec <= 0f) 0f else frames / dtSec + val frametime = if (fps > 0f) 1000f / fps else 0f + RetroPerformanceHudState.updateValues( + RetroPerformanceHudState.Snapshot( + fps = fps, + frametimeMs = frametime, + gpuLoad = readGpuLoad(), + cpuPercent = readCpuPercent(), + ramPercent = readRamPercent(), + batteryWatts = readBatteryWatts(), + tempC = readBatteryTempC(), + console = console, + ), + ) + } + } + } + + fun stop() { + job?.cancel() + job = null + } + + private fun readCpuPercent(): Int = + try { + val sample = CPUStatus.readAppCpuSample() + val previous = prevCpuSample + prevCpuSample = sample + if (sample != null && previous != null) sample.percentSince(previous) else -1 + } catch (_: Exception) { + -1 + } + + private fun readGpuLoad(): Int { + val value = readGpuLoadRaw() + val now = SystemClock.elapsedRealtime() + if (value >= 0) { + lastGoodGpu = value + lastGoodGpuTime = now + return value + } + return if (lastGoodGpu >= 0 && now - lastGoodGpuTime < 5000) lastGoodGpu else -1 + } + + private fun readGpuLoadRaw(): Int { + val simpleFiles = + arrayOf( + "/sys/class/kgsl/kgsl-3d0/gpu_busy_percentage", + "/sys/class/kgsl/kgsl-3d0/devfreq/gpu_load", + "/sys/class/misc/mali0/device/utilisation", + "/sys/kernel/gpu/gpu_busy", + ) + for (path in simpleFiles) { + val file = File(path) + if (file.exists() && file.canRead()) { + try { + BufferedReader(FileReader(file)).use { reader -> + val line = reader.readLine() + if (line != null) { + val digits = line.trim().replace(Regex("[^0-9]"), "") + if (digits.isNotEmpty()) return digits.toInt() + } + } + } catch (_: Exception) { + } + } + } + val gpubusy = File("/sys/class/kgsl/kgsl-3d0/gpubusy") + if (gpubusy.exists() && gpubusy.canRead()) { + try { + BufferedReader(FileReader(gpubusy)).use { reader -> + val parts = reader.readLine()?.trim()?.split(Regex("\\s+")) + if (parts != null && parts.size >= 2) { + val busy = parts[0].toLong() + val total = parts[1].toLong() + if (total != 0L) return ((100 * busy) / total).toInt() + } + } + } catch (_: Exception) { + } + } + return -1 + } + + private fun readRamPercent(): Int = + try { + val mi = ActivityManager.MemoryInfo() + (activity.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager).getMemoryInfo(mi) + (((mi.totalMem - mi.availMem) * 100) / mi.totalMem).toInt() + } catch (_: Exception) { + -1 + } + + private fun batteryIntent(): Intent? = + activity.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) + + private fun readBatteryWatts(): Float = + try { + val bm = activity.getSystemService(Context.BATTERY_SERVICE) as? BatteryManager + var currentRaw = bm?.getLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW) ?: 0L + if (currentRaw == 0L || currentRaw == Long.MIN_VALUE) { + currentRaw = readSysFsLong("/sys/class/power_supply/battery/current_now") + } + if (currentRaw == 0L || currentRaw == Long.MIN_VALUE) { + -1f + } else { + val currentAbs = Math.abs(currentRaw) + val amps = if (currentAbs < 20000) currentAbs / 1000f else currentAbs / 1000000f + val mv = batteryIntent()?.getIntExtra(BatteryManager.EXTRA_VOLTAGE, 0) ?: 0 + if (mv > 0 && amps > 0f) (mv / 1000f) * amps else -1f + } + } catch (_: Exception) { + -1f + } + + private fun readBatteryTempC(): Int = + try { + val temp = batteryIntent()?.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, 0) ?: 0 + if (temp > 0) temp / 10 else -1 + } catch (_: Exception) { + -1 + } + + private fun readSysFsLong(path: String): Long = + try { + val file = File(path) + if (file.exists() && file.canRead()) { + BufferedReader(FileReader(file)).use { it.readLine()?.trim()?.toLong() ?: 0L } + } else { + 0L + } + } catch (_: Exception) { + 0L + } +} + +private val HudAccent = Color(0xFF1A9FFF) +private val HudGood = Color(0xFF35D0BA) +private val HudWarn = Color(0xFFFFB020) +private val HudBad = Color(0xFFFF5A5A) +private val HudText = Color(0xFFF0F4FF) +private val HudSub = Color(0xFF7A8FA8) +private val HudTrack = Color(0x33FFFFFF) + +private data class RetroGaugeSpec( + val label: String, + val value: String, + val fraction: Float, + val color: Color, + val sublabel: String? = null, + val sublabelColor: Color = HudSub, +) + +@Composable +fun RetroPerformanceHudOverlay(modifier: Modifier = Modifier) { + val s by RetroPerformanceHudState.state.collectAsState() + val gauges = ArrayList(6) + gauges.add(RetroGaugeSpec("FPS", s.fps.toInt().toString(), s.fps / 120f, HudAccent)) + gauges.add(RetroGaugeSpec("GPU", pctText(s.gpuLoad), pctFraction(s.gpuLoad), loadColor(maxOf(s.gpuLoad, 0)))) + gauges.add( + RetroGaugeSpec( + "CPU", + pctText(maxOf(s.cpuPercent, 0)), + pctFraction(maxOf(s.cpuPercent, 0)), + loadColor(maxOf(s.cpuPercent, 0)), + ), + ) + gauges.add(RetroGaugeSpec("RAM", pctText(s.ramPercent), pctFraction(s.ramPercent), loadColor(maxOf(s.ramPercent, 0)))) + gauges.add( + RetroGaugeSpec( + "ms", + String.format("%.1f", s.frametimeMs), + 1f - (s.frametimeMs / 33.3f), + HudGood, + ), + ) + gauges.add( + RetroGaugeSpec( + "WATT", + if (s.batteryWatts >= 0f) String.format("%.1f", s.batteryWatts) else "N/A", + if (s.batteryWatts >= 0f) s.batteryWatts / 12f else 0f, + HudAccent, + sublabel = if (s.tempC >= 0) "${s.tempC}°C" else null, + sublabelColor = if (s.tempC >= 0) tempColor(s.tempC) else HudSub, + ), + ) + val showConsole = s.console.isNotEmpty() + Box( + modifier = modifier.fillMaxSize().background(Color(0xF00A0D13)), + ) { + Column( + modifier = + Modifier + .align(Alignment.Center) + .padding(horizontal = 16.dp) + .padding(bottom = if (showConsole) 48.dp else 0.dp), + verticalArrangement = Arrangement.spacedBy(18.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + gauges.chunked(3).forEach { rowGauges -> + Row(horizontalArrangement = Arrangement.spacedBy(18.dp, Alignment.CenterHorizontally)) { + rowGauges.forEach { g -> + RetroHudGauge(g.label, g.value, g.fraction, g.color, g.sublabel, g.sublabelColor) + } + } + } + } + if (showConsole) { + Text( + s.console, + color = HudAccent, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + modifier = + Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 18.dp) + .clip(RoundedCornerShape(9.dp)) + .background(Color(0x1A1A9FFF)) + .padding(horizontal = 16.dp, vertical = 6.dp), + ) + } + } +} + +private fun pctText(v: Int): String = if (v >= 0) "$v%" else "N/A" + +private fun pctFraction(v: Int): Float = if (v >= 0) v / 100f else 0f + +private fun loadColor(pct: Int): Color = if (pct >= 90) HudBad else if (pct >= 70) HudWarn else HudGood + +private fun tempColor(c: Int): Color = if (c >= 45) HudBad else if (c >= 40) HudWarn else HudGood + +@Composable +private fun RetroHudGauge( + label: String, + valueText: String, + fraction: Float, + accent: Color, + sublabel: String? = null, + sublabelColor: Color = HudSub, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(86.dp)) { + Canvas(modifier = Modifier.fillMaxSize()) { + val stroke = 7.dp.toPx() + val inset = stroke / 2f + val arcSize = Size(size.width - stroke, size.height - stroke) + drawArc( + color = HudTrack, + startAngle = 135f, + sweepAngle = 270f, + useCenter = false, + topLeft = Offset(inset, inset), + size = arcSize, + style = Stroke(width = stroke, cap = StrokeCap.Round), + ) + drawArc( + color = accent, + startAngle = 135f, + sweepAngle = 270f * fraction.coerceIn(0f, 1f), + useCenter = false, + topLeft = Offset(inset, inset), + size = arcSize, + style = Stroke(width = stroke, cap = StrokeCap.Round), + ) + } + Text(valueText, color = HudText, fontSize = 21.sp, fontWeight = FontWeight.Bold) + } + Text(label, color = HudSub, fontSize = 13.sp, fontWeight = FontWeight.Medium) + if (sublabel != null) { + Text(sublabel, color = sublabelColor, fontSize = 12.sp, fontWeight = FontWeight.Medium) + } + } +} diff --git a/app/src/main/feature/retro/RetroShortcuts.kt b/app/src/main/feature/retro/RetroShortcuts.kt index 64ab91dd0..1ab1c14a3 100644 --- a/app/src/main/feature/retro/RetroShortcuts.kt +++ b/app/src/main/feature/retro/RetroShortcuts.kt @@ -16,6 +16,7 @@ object RetroShortcuts { const val KEY_SHADER = "retro_shader" const val KEY_TOUCH_CONTROLS = "retro_touch_controls" const val KEY_AUDIO = "retro_audio" + const val KEY_HUD = "retro_hud" const val VAR_PREFIX = "retro_var_" fun coreVariables(shortcut: Shortcut): HashMap { @@ -92,6 +93,7 @@ object RetroShortcuts { putExtra(RetroActivity.EXTRA_SHADER, shortcut.getExtra(KEY_SHADER, "default")) putExtra(RetroActivity.EXTRA_TOUCH_CONTROLS, shortcut.getExtra(KEY_TOUCH_CONTROLS, "1") != "0") putExtra(RetroActivity.EXTRA_AUDIO, shortcut.getExtra(KEY_AUDIO, "1") != "0") + putExtra(RetroActivity.EXTRA_HUD, shortcut.getExtra(KEY_HUD, "0") == "1") putExtra(RetroActivity.EXTRA_VARIABLES, coreVariables(shortcut)) } } From 75a6fb93254b742e3256364f688e9cedc74c7826 Mon Sep 17 00:00:00 2001 From: max Date: Mon, 13 Jul 2026 16:00:16 -0400 Subject: [PATCH 13/38] Use the real draggable FrameRating HUD for retro games - Replace the gauge-style overlay with a second instance of the same FrameRating view PC games use: the compact top HUD with the full structure, drag-to-move, tap interactions, anchor and display-mode persistence, and the frametime graph, all self-contained in the existing view - The renderer slot shows the console name (NES, GB, PS1, ...) instead of DXVK/VKD3D; stats (FPS, GPU, CPU, RAM, battery, temperature) come from the view's own collectors, with FPS fed from the emulator's rendered frames - Container hudSettings (transparency, scale, element toggles) are honored exactly like PC sessions; the PC HUD path itself is untouched --- app/src/main/feature/retro/RetroActivity.kt | 107 +++-- .../main/feature/retro/RetroPerformanceHud.kt | 382 ------------------ 2 files changed, 74 insertions(+), 415 deletions(-) delete mode 100644 app/src/main/feature/retro/RetroPerformanceHud.kt diff --git a/app/src/main/feature/retro/RetroActivity.kt b/app/src/main/feature/retro/RetroActivity.kt index e40726848..9ced325f6 100644 --- a/app/src/main/feature/retro/RetroActivity.kt +++ b/app/src/main/feature/retro/RetroActivity.kt @@ -10,13 +10,6 @@ import android.view.View import android.view.WindowManager import android.widget.FrameLayout import android.widget.Toast -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.ComposeView import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat @@ -29,6 +22,7 @@ import com.swordfish.libretrodroid.ShaderConfig import com.swordfish.libretrodroid.Variable import com.winlator.cmod.runtime.container.ContainerManager import com.winlator.cmod.runtime.container.Shortcut +import com.winlator.cmod.runtime.display.ui.FrameRating import com.winlator.cmod.runtime.input.controls.ExternalController import com.winlator.cmod.shared.android.FixedFontScaleAppCompatActivity import com.winlator.cmod.shared.theme.WinNativeTheme @@ -76,7 +70,9 @@ class RetroActivity : FixedFontScaleAppCompatActivity(), RetroInputView.Listener private var controllerConnected = false private var inputManager: InputManager? = null private var hudVisible = false - private var statsCollector: RetroStatsCollector? = null + private var frameRating: FrameRating? = null + private var rootLayout: FrameLayout? = null + private var menuComposeView: ComposeView? = null private val inputDeviceListener = object : InputManager.InputDeviceListener { @@ -185,6 +181,7 @@ class RetroActivity : FixedFontScaleAppCompatActivity(), RetroInputView.Listener lifecycle.addObserver(retroView) val root = FrameLayout(this) + rootLayout = root root.addView( retroView, FrameLayout.LayoutParams( @@ -207,34 +204,15 @@ class RetroActivity : FixedFontScaleAppCompatActivity(), RetroInputView.Listener menu.bottomProvider = { buildBottomEntries() } menu.tabs = RetroDrawerTabs.build(resolvedSystem, RetroCoreOptions.forSystem(resolvedSystem).isNotEmpty()) hudVisible = intent.getBooleanExtra(EXTRA_HUD, false) - statsCollector = RetroStatsCollector(this, resolvedSystem.shortName) - RetroPerformanceHudState.setVisible(hudVisible) val menuView = ComposeView(this).apply { setContent { WinNativeTheme { - val hudOn by RetroPerformanceHudState.visible.collectAsState() - if (hudOn) { - Box( - modifier = - Modifier - .fillMaxHeight() - .fillMaxWidth(0.5f) - .pointerInput(Unit) { - awaitPointerEventScope { - while (true) { - awaitPointerEvent().changes.forEach { it.consume() } - } - } - }, - ) { - RetroPerformanceHudOverlay() - } - } RetroDrawerMenu(menu) } } } + menuComposeView = menuView root.addView( menuView, FrameLayout.LayoutParams( @@ -248,7 +226,7 @@ class RetroActivity : FixedFontScaleAppCompatActivity(), RetroInputView.Listener inputManager = getSystemService(InputManager::class.java) inputManager?.registerInputDeviceListener(inputDeviceListener, null) refreshControllerPresence() - statsCollector?.start(lifecycleScope) + if (hudVisible) showHud() recordLaunchStats() observeErrors() observeEvents() @@ -256,11 +234,72 @@ class RetroActivity : FixedFontScaleAppCompatActivity(), RetroInputView.Listener override fun onDestroy() { inputManager?.unregisterInputDeviceListener(inputDeviceListener) - statsCollector?.stop() - RetroPerformanceHudState.setVisible(false) super.onDestroy() } + private fun showHud() { + var rating = frameRating + if (rating == null) { + val root = rootLayout ?: return + rating = FrameRating(this, HashMap()) + rating.setRenderer(system?.shortName ?: "Retro") + rating.visibility = View.GONE + frameRating = rating + val menuIndex = menuComposeView?.let { root.indexOfChild(it) } ?: -1 + if (menuIndex >= 0) root.addView(rating, menuIndex) else root.addView(rating) + applyRetroHudSettings(rating) + } + rating.visibility = View.VISIBLE + rating.reset() + } + + private fun applyRetroHudSettings(rating: FrameRating) { + lifecycleScope.launch(Dispatchers.IO) { + val json = + runCatching { + val containerId = intent.getIntExtra(EXTRA_CONTAINER_ID, 0) + if (containerId <= 0) return@runCatching null + ContainerManager(this@RetroActivity) + .getContainerById(containerId) + ?.getExtra("hudSettings") + }.getOrNull() + if (json.isNullOrEmpty()) return@launch + runCatching { + val obj = org.json.JSONObject(json) + val transparency = obj.optDouble("transparency", 1.0).toFloat() + val decoupled = obj.optBoolean("backgroundAlphaDecoupled", false) + val backgroundTransparency = + obj + .optDouble( + "backgroundTransparency", + (transparency * FrameRating.BACKDROP_BASE_ALPHA).toDouble(), + ).toFloat() + val scale = obj.optDouble("scale", 1.0).toFloat() + val legacyCpuRam = obj.optBoolean("showCpuRam", true) + val legacyBattTemp = obj.optBoolean("showBattTemp", true) + val elements = + booleanArrayOf( + obj.optBoolean("showFPS", true), + obj.optBoolean("showRenderer", true), + obj.optBoolean("showGPU", true), + obj.optBoolean("showCPU", legacyCpuRam), + obj.optBoolean("showRAM", legacyCpuRam), + obj.optBoolean("showBattery", legacyBattTemp), + obj.optBoolean("showTemp", legacyBattTemp), + obj.optBoolean("showGraph", true), + obj.optBoolean("showCpuTemp", false), + ) + runOnUiThread { + rating.setHudAlpha(transparency) + rating.setBackgroundAlphaDecoupled(decoupled) + rating.setHudBackgroundAlpha(backgroundTransparency) + rating.setHudScale(scale) + elements.forEachIndexed { index, enabled -> rating.toggleElement(index, enabled) } + } + } + } + } + private fun recordLaunchStats() { val prefs = getSharedPreferences("playtime_stats", MODE_PRIVATE) playtimePrefs = prefs @@ -290,7 +329,9 @@ class RetroActivity : FixedFontScaleAppCompatActivity(), RetroInputView.Listener .getGLRetroEvents() .onEach { event -> when (event) { - is GLRetroView.GLRetroEvents.FrameRendered -> statsCollector?.onFrame() + is GLRetroView.GLRetroEvents.FrameRendered -> { + if (hudVisible) frameRating?.recordGameFrame() + } is GLRetroView.GLRetroEvents.SurfaceCreated -> { if (!audioEnabledSetting) retroView.audioEnabled = false lifecycleScope.launch(Dispatchers.Default) { @@ -430,7 +471,7 @@ class RetroActivity : FixedFontScaleAppCompatActivity(), RetroInputView.Listener entries += RetroMenuEntry.Action("HUD", RetroDrawerIcons.Hud, active = hudVisible) { hudVisible = !hudVisible - RetroPerformanceHudState.setVisible(hudVisible) + if (hudVisible) showHud() else frameRating?.visibility = View.GONE persistExtra(RetroShortcuts.KEY_HUD, if (hudVisible) "1" else "0") menu.rebuild() } diff --git a/app/src/main/feature/retro/RetroPerformanceHud.kt b/app/src/main/feature/retro/RetroPerformanceHud.kt deleted file mode 100644 index 324ef7cf4..000000000 --- a/app/src/main/feature/retro/RetroPerformanceHud.kt +++ /dev/null @@ -1,382 +0,0 @@ -package com.winlator.cmod.feature.retro - -import android.app.Activity -import android.app.ActivityManager -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.os.BatteryManager -import android.os.SystemClock -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.winlator.cmod.runtime.system.CPUStatus -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import java.io.BufferedReader -import java.io.File -import java.io.FileReader -import java.util.concurrent.atomic.AtomicInteger -import java.util.concurrent.atomic.AtomicLong - -object RetroPerformanceHudState { - data class Snapshot( - val fps: Float = 0f, - val frametimeMs: Float = 0f, - val gpuLoad: Int = -1, - val cpuPercent: Int = -1, - val ramPercent: Int = -1, - val batteryWatts: Float = -1f, - val tempC: Int = -1, - val console: String = "", - ) - - private val _state = MutableStateFlow(Snapshot()) - val state: StateFlow = _state.asStateFlow() - private val _visible = MutableStateFlow(false) - val visible: StateFlow = _visible.asStateFlow() - - fun setVisible(v: Boolean) { - _visible.value = v - } - - fun updateValues(snapshot: Snapshot) { - _state.value = snapshot - } -} - -class RetroStatsCollector( - private val activity: Activity, - private val console: String, -) { - private val frameCount = AtomicInteger(0) - private val lastFrameNano = AtomicLong(0L) - private var prevCpuSample: CPUStatus.AppCpuSample? = null - private var lastGoodGpu = -1 - private var lastGoodGpuTime = 0L - private var job: Job? = null - - fun onFrame() { - frameCount.incrementAndGet() - lastFrameNano.set(System.nanoTime()) - } - - fun start(scope: CoroutineScope) { - if (job != null) return - job = - scope.launch(Dispatchers.Default) { - var lastTick = System.nanoTime() - while (isActive) { - delay(1000) - val now = System.nanoTime() - val frames = frameCount.getAndSet(0) - val dtSec = (now - lastTick) / 1e9f - lastTick = now - val stalled = now - lastFrameNano.get() > 1_500_000_000L - val fps = if (stalled || dtSec <= 0f) 0f else frames / dtSec - val frametime = if (fps > 0f) 1000f / fps else 0f - RetroPerformanceHudState.updateValues( - RetroPerformanceHudState.Snapshot( - fps = fps, - frametimeMs = frametime, - gpuLoad = readGpuLoad(), - cpuPercent = readCpuPercent(), - ramPercent = readRamPercent(), - batteryWatts = readBatteryWatts(), - tempC = readBatteryTempC(), - console = console, - ), - ) - } - } - } - - fun stop() { - job?.cancel() - job = null - } - - private fun readCpuPercent(): Int = - try { - val sample = CPUStatus.readAppCpuSample() - val previous = prevCpuSample - prevCpuSample = sample - if (sample != null && previous != null) sample.percentSince(previous) else -1 - } catch (_: Exception) { - -1 - } - - private fun readGpuLoad(): Int { - val value = readGpuLoadRaw() - val now = SystemClock.elapsedRealtime() - if (value >= 0) { - lastGoodGpu = value - lastGoodGpuTime = now - return value - } - return if (lastGoodGpu >= 0 && now - lastGoodGpuTime < 5000) lastGoodGpu else -1 - } - - private fun readGpuLoadRaw(): Int { - val simpleFiles = - arrayOf( - "/sys/class/kgsl/kgsl-3d0/gpu_busy_percentage", - "/sys/class/kgsl/kgsl-3d0/devfreq/gpu_load", - "/sys/class/misc/mali0/device/utilisation", - "/sys/kernel/gpu/gpu_busy", - ) - for (path in simpleFiles) { - val file = File(path) - if (file.exists() && file.canRead()) { - try { - BufferedReader(FileReader(file)).use { reader -> - val line = reader.readLine() - if (line != null) { - val digits = line.trim().replace(Regex("[^0-9]"), "") - if (digits.isNotEmpty()) return digits.toInt() - } - } - } catch (_: Exception) { - } - } - } - val gpubusy = File("/sys/class/kgsl/kgsl-3d0/gpubusy") - if (gpubusy.exists() && gpubusy.canRead()) { - try { - BufferedReader(FileReader(gpubusy)).use { reader -> - val parts = reader.readLine()?.trim()?.split(Regex("\\s+")) - if (parts != null && parts.size >= 2) { - val busy = parts[0].toLong() - val total = parts[1].toLong() - if (total != 0L) return ((100 * busy) / total).toInt() - } - } - } catch (_: Exception) { - } - } - return -1 - } - - private fun readRamPercent(): Int = - try { - val mi = ActivityManager.MemoryInfo() - (activity.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager).getMemoryInfo(mi) - (((mi.totalMem - mi.availMem) * 100) / mi.totalMem).toInt() - } catch (_: Exception) { - -1 - } - - private fun batteryIntent(): Intent? = - activity.registerReceiver(null, IntentFilter(Intent.ACTION_BATTERY_CHANGED)) - - private fun readBatteryWatts(): Float = - try { - val bm = activity.getSystemService(Context.BATTERY_SERVICE) as? BatteryManager - var currentRaw = bm?.getLongProperty(BatteryManager.BATTERY_PROPERTY_CURRENT_NOW) ?: 0L - if (currentRaw == 0L || currentRaw == Long.MIN_VALUE) { - currentRaw = readSysFsLong("/sys/class/power_supply/battery/current_now") - } - if (currentRaw == 0L || currentRaw == Long.MIN_VALUE) { - -1f - } else { - val currentAbs = Math.abs(currentRaw) - val amps = if (currentAbs < 20000) currentAbs / 1000f else currentAbs / 1000000f - val mv = batteryIntent()?.getIntExtra(BatteryManager.EXTRA_VOLTAGE, 0) ?: 0 - if (mv > 0 && amps > 0f) (mv / 1000f) * amps else -1f - } - } catch (_: Exception) { - -1f - } - - private fun readBatteryTempC(): Int = - try { - val temp = batteryIntent()?.getIntExtra(BatteryManager.EXTRA_TEMPERATURE, 0) ?: 0 - if (temp > 0) temp / 10 else -1 - } catch (_: Exception) { - -1 - } - - private fun readSysFsLong(path: String): Long = - try { - val file = File(path) - if (file.exists() && file.canRead()) { - BufferedReader(FileReader(file)).use { it.readLine()?.trim()?.toLong() ?: 0L } - } else { - 0L - } - } catch (_: Exception) { - 0L - } -} - -private val HudAccent = Color(0xFF1A9FFF) -private val HudGood = Color(0xFF35D0BA) -private val HudWarn = Color(0xFFFFB020) -private val HudBad = Color(0xFFFF5A5A) -private val HudText = Color(0xFFF0F4FF) -private val HudSub = Color(0xFF7A8FA8) -private val HudTrack = Color(0x33FFFFFF) - -private data class RetroGaugeSpec( - val label: String, - val value: String, - val fraction: Float, - val color: Color, - val sublabel: String? = null, - val sublabelColor: Color = HudSub, -) - -@Composable -fun RetroPerformanceHudOverlay(modifier: Modifier = Modifier) { - val s by RetroPerformanceHudState.state.collectAsState() - val gauges = ArrayList(6) - gauges.add(RetroGaugeSpec("FPS", s.fps.toInt().toString(), s.fps / 120f, HudAccent)) - gauges.add(RetroGaugeSpec("GPU", pctText(s.gpuLoad), pctFraction(s.gpuLoad), loadColor(maxOf(s.gpuLoad, 0)))) - gauges.add( - RetroGaugeSpec( - "CPU", - pctText(maxOf(s.cpuPercent, 0)), - pctFraction(maxOf(s.cpuPercent, 0)), - loadColor(maxOf(s.cpuPercent, 0)), - ), - ) - gauges.add(RetroGaugeSpec("RAM", pctText(s.ramPercent), pctFraction(s.ramPercent), loadColor(maxOf(s.ramPercent, 0)))) - gauges.add( - RetroGaugeSpec( - "ms", - String.format("%.1f", s.frametimeMs), - 1f - (s.frametimeMs / 33.3f), - HudGood, - ), - ) - gauges.add( - RetroGaugeSpec( - "WATT", - if (s.batteryWatts >= 0f) String.format("%.1f", s.batteryWatts) else "N/A", - if (s.batteryWatts >= 0f) s.batteryWatts / 12f else 0f, - HudAccent, - sublabel = if (s.tempC >= 0) "${s.tempC}°C" else null, - sublabelColor = if (s.tempC >= 0) tempColor(s.tempC) else HudSub, - ), - ) - val showConsole = s.console.isNotEmpty() - Box( - modifier = modifier.fillMaxSize().background(Color(0xF00A0D13)), - ) { - Column( - modifier = - Modifier - .align(Alignment.Center) - .padding(horizontal = 16.dp) - .padding(bottom = if (showConsole) 48.dp else 0.dp), - verticalArrangement = Arrangement.spacedBy(18.dp, Alignment.CenterVertically), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - gauges.chunked(3).forEach { rowGauges -> - Row(horizontalArrangement = Arrangement.spacedBy(18.dp, Alignment.CenterHorizontally)) { - rowGauges.forEach { g -> - RetroHudGauge(g.label, g.value, g.fraction, g.color, g.sublabel, g.sublabelColor) - } - } - } - } - if (showConsole) { - Text( - s.console, - color = HudAccent, - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - modifier = - Modifier - .align(Alignment.BottomCenter) - .padding(bottom = 18.dp) - .clip(RoundedCornerShape(9.dp)) - .background(Color(0x1A1A9FFF)) - .padding(horizontal = 16.dp, vertical = 6.dp), - ) - } - } -} - -private fun pctText(v: Int): String = if (v >= 0) "$v%" else "N/A" - -private fun pctFraction(v: Int): Float = if (v >= 0) v / 100f else 0f - -private fun loadColor(pct: Int): Color = if (pct >= 90) HudBad else if (pct >= 70) HudWarn else HudGood - -private fun tempColor(c: Int): Color = if (c >= 45) HudBad else if (c >= 40) HudWarn else HudGood - -@Composable -private fun RetroHudGauge( - label: String, - valueText: String, - fraction: Float, - accent: Color, - sublabel: String? = null, - sublabelColor: Color = HudSub, -) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - Box(contentAlignment = Alignment.Center, modifier = Modifier.size(86.dp)) { - Canvas(modifier = Modifier.fillMaxSize()) { - val stroke = 7.dp.toPx() - val inset = stroke / 2f - val arcSize = Size(size.width - stroke, size.height - stroke) - drawArc( - color = HudTrack, - startAngle = 135f, - sweepAngle = 270f, - useCenter = false, - topLeft = Offset(inset, inset), - size = arcSize, - style = Stroke(width = stroke, cap = StrokeCap.Round), - ) - drawArc( - color = accent, - startAngle = 135f, - sweepAngle = 270f * fraction.coerceIn(0f, 1f), - useCenter = false, - topLeft = Offset(inset, inset), - size = arcSize, - style = Stroke(width = stroke, cap = StrokeCap.Round), - ) - } - Text(valueText, color = HudText, fontSize = 21.sp, fontWeight = FontWeight.Bold) - } - Text(label, color = HudSub, fontSize = 13.sp, fontWeight = FontWeight.Medium) - if (sublabel != null) { - Text(sublabel, color = sublabelColor, fontSize = 12.sp, fontWeight = FontWeight.Medium) - } - } -} From 3108342d88c8b8f4d8981dcd675cae5cf5dac3b3 Mon Sep 17 00:00:00 2001 From: max Date: Mon, 13 Jul 2026 16:08:09 -0400 Subject: [PATCH 14/38] Draw the retro drawer above the performance HUD and make menu cards perfect squares --- app/src/main/feature/retro/RetroActivity.kt | 1 + app/src/main/feature/retro/RetroDrawerMenu.kt | 72 +++++++++---------- 2 files changed, 34 insertions(+), 39 deletions(-) diff --git a/app/src/main/feature/retro/RetroActivity.kt b/app/src/main/feature/retro/RetroActivity.kt index 9ced325f6..cf60aff5e 100644 --- a/app/src/main/feature/retro/RetroActivity.kt +++ b/app/src/main/feature/retro/RetroActivity.kt @@ -206,6 +206,7 @@ class RetroActivity : FixedFontScaleAppCompatActivity(), RetroInputView.Listener hudVisible = intent.getBooleanExtra(EXTRA_HUD, false) val menuView = ComposeView(this).apply { + elevation = 2000f setContent { WinNativeTheme { RetroDrawerMenu(menu) diff --git a/app/src/main/feature/retro/RetroDrawerMenu.kt b/app/src/main/feature/retro/RetroDrawerMenu.kt index cc17a89fc..00d228b4d 100644 --- a/app/src/main/feature/retro/RetroDrawerMenu.kt +++ b/app/src/main/feature/retro/RetroDrawerMenu.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.defaultMinSize @@ -541,47 +542,40 @@ private fun RetroActionGrid( ) { val actions = controller.entries val spacing = (8f * paneScale).dp - val verticalPadding = (10f * paneScale).dp - BoxWithConstraints(modifier = Modifier.fillMaxSize()) { - val rows = ((actions.size + controller.gridColumns - 1) / controller.gridColumns).coerceAtLeast(1) - val rowHeight = - ((maxHeight - verticalPadding * 2 - spacing * (rows - 1)) / rows) - .coerceAtLeast((72f * paneScale).dp) - Column( - modifier = - Modifier - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding(horizontal = (10f * paneScale).dp, vertical = verticalPadding), - verticalArrangement = Arrangement.spacedBy(spacing), - ) { - actions.chunked(controller.gridColumns).forEachIndexed { rowIndex, row -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(spacing), - ) { - row.forEachIndexed { colIndex, entry -> - val flatIndex = rowIndex * controller.gridColumns + colIndex - if (entry is RetroMenuEntry.Action) { - RetroActionCard( - entry = entry, - highlighted = - controller.controllerActive && - controller.region == 1 && - controller.contentIndex == flatIndex, - paneScale = paneScale, - modifier = Modifier.weight(1f).height(rowHeight), - onClick = { - controller.contentIndex = flatIndex - entry.onClick() - }, - ) - } - } - repeat(controller.gridColumns - row.size) { - Spacer(Modifier.weight(1f)) + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = (10f * paneScale).dp, vertical = (10f * paneScale).dp), + verticalArrangement = Arrangement.spacedBy(spacing), + ) { + actions.chunked(controller.gridColumns).forEachIndexed { rowIndex, row -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(spacing), + ) { + row.forEachIndexed { colIndex, entry -> + val flatIndex = rowIndex * controller.gridColumns + colIndex + if (entry is RetroMenuEntry.Action) { + RetroActionCard( + entry = entry, + highlighted = + controller.controllerActive && + controller.region == 1 && + controller.contentIndex == flatIndex, + paneScale = paneScale, + modifier = Modifier.weight(1f).aspectRatio(1f), + onClick = { + controller.contentIndex = flatIndex + entry.onClick() + }, + ) } } + repeat(controller.gridColumns - row.size) { + Spacer(Modifier.weight(1f)) + } } } } From 169a8b0fc9f7bc3ab274f224daa000fa06c868e7 Mon Sep 17 00:00:00 2001 From: max Date: Mon, 13 Jul 2026 16:17:17 -0400 Subject: [PATCH 15/38] Fix launch crash when the retro HUD is enabled at startup FrameRating.onAttachedToWindow calls bringToFront, and creating it during onCreate reordered the root's children in the middle of the window's first attach dispatch, leaving the drawer ComposeView unattached when the first measure pass reached it. HUD creation at launch is now posted until after the first attach completes, matching how PC sessions only ever add the HUD to an already-stable window. --- app/src/main/feature/retro/RetroActivity.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/feature/retro/RetroActivity.kt b/app/src/main/feature/retro/RetroActivity.kt index cf60aff5e..bf1032547 100644 --- a/app/src/main/feature/retro/RetroActivity.kt +++ b/app/src/main/feature/retro/RetroActivity.kt @@ -227,7 +227,11 @@ class RetroActivity : FixedFontScaleAppCompatActivity(), RetroInputView.Listener inputManager = getSystemService(InputManager::class.java) inputManager?.registerInputDeviceListener(inputDeviceListener, null) refreshControllerPresence() - if (hudVisible) showHud() + if (hudVisible) { + root.post { + if (!isFinishing && !isDestroyed && hudVisible) showHud() + } + } recordLaunchStats() observeErrors() observeEvents() From 02ceac0d339ea10f06466b6a2eea490d91aa6584 Mon Sep 17 00:00:00 2001 From: max Date: Tue, 14 Jul 2026 09:38:04 -0400 Subject: [PATCH 16/38] Raise touch controls, add portrait mode for handhelds, and fix retro HUD FPS - Landscape controls sit higher: the bottom gap for the d-pad, face cluster, and stick grew from 6 to 9 screen units so nothing hugs the screen edge; N64 specifically moves the analog stick to the primary bottom-left spot with the d-pad above it and Menu joining the Select/Start pill row - GB, GBC, GBA, and Game Gear now allow portrait play: the game renders in the top half (top-aligned viewport) and the controls fill the bottom half laid out like the real handhelds, with the d-pad left and A/B as the classic diagonal pair on the right at the same row height, Start/Select centered at the bottom, and GBA shoulder pills at the control-zone corners - With the HUD enabled in portrait the emulator viewport is pushed down by exactly the HUD's height (capped) so the picture sits just below it and is never covered; landscape keeps the floating PC-style behavior - HUD FPS now reports the emulator's real frame rate: duplicate GL redraws are skipped so rendered-frame events match actual core video refreshes instead of the display's refresh cadence --- app/src/main/feature/retro/RetroActivity.kt | 60 ++++++++++- app/src/main/feature/retro/RetroInputView.kt | 101 +++++++++++++++++-- 2 files changed, 150 insertions(+), 11 deletions(-) diff --git a/app/src/main/feature/retro/RetroActivity.kt b/app/src/main/feature/retro/RetroActivity.kt index bf1032547..d6f80c9e4 100644 --- a/app/src/main/feature/retro/RetroActivity.kt +++ b/app/src/main/feature/retro/RetroActivity.kt @@ -1,6 +1,9 @@ package com.winlator.cmod.feature.retro import android.content.SharedPreferences +import android.content.pm.ActivityInfo +import android.content.res.Configuration +import android.graphics.RectF import android.hardware.input.InputManager import android.os.Bundle import android.view.InputDevice @@ -20,6 +23,7 @@ import com.swordfish.libretrodroid.GLRetroViewData import com.swordfish.libretrodroid.LibretroDroid import com.swordfish.libretrodroid.ShaderConfig import com.swordfish.libretrodroid.Variable +import com.swordfish.libretrodroid.ViewportAlignment import com.winlator.cmod.runtime.container.ContainerManager import com.winlator.cmod.runtime.container.Shortcut import com.winlator.cmod.runtime.display.ui.FrameRating @@ -73,6 +77,41 @@ class RetroActivity : FixedFontScaleAppCompatActivity(), RetroInputView.Listener private var frameRating: FrameRating? = null private var rootLayout: FrameLayout? = null private var menuComposeView: ComposeView? = null + private var surfaceReady = false + + private val portraitCapable: Boolean + get() = + system?.id in + setOf( + RetroSystems.GAMEBOY.id, + RetroSystems.GAMEBOY_COLOR.id, + RetroSystems.GBA.id, + RetroSystems.GAME_GEAR.id, + ) + + private val isPortrait: Boolean + get() = resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT + + private fun applyDisplayGeometry() { + if (!surfaceReady || !retroReady) return + retroView.viewportAlignment = if (isPortrait) ViewportAlignment.TOP else ViewportAlignment.CENTER + val rating = frameRating + val rootHeight = rootLayout?.height ?: 0 + val push = + if (isPortrait && hudVisible && rating != null && + rating.visibility == View.VISIBLE && rating.height > 0 && rootHeight > 0 + ) { + ((rating.y + rating.height + rating.height * 0.15f) / rootHeight).coerceIn(0f, 0.3f) + } else { + 0f + } + retroView.viewport = RectF(0f, push, 1f, 1f) + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + rootLayout?.post { applyDisplayGeometry() } + } private val inputDeviceListener = object : InputManager.InputDeviceListener { @@ -140,6 +179,10 @@ class RetroActivity : FixedFontScaleAppCompatActivity(), RetroInputView.Listener return } + if (portraitCapable) { + requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR + } + val coreFile = RetroCoreManager.coreFile(this, resolvedSystem) if (!coreFile.isFile) { Toast.makeText(this, "Core not installed: ${resolvedSystem.coreFileName}", Toast.LENGTH_LONG).show() @@ -174,6 +217,13 @@ class RetroActivity : FixedFontScaleAppCompatActivity(), RetroInputView.Listener variables = coreVars.map { Variable(it.key, it.value) }.toTypedArray() rumbleEventsEnabled = true preferLowLatencyAudio = true + skipDuplicateFrames = true + viewportAlignment = + if (resources.configuration.orientation == Configuration.ORIENTATION_PORTRAIT) { + ViewportAlignment.TOP + } else { + ViewportAlignment.CENTER + } if (sramFile.isFile) saveRAMState = runCatching { sramFile.readBytes() }.getOrNull() } @@ -256,6 +306,7 @@ class RetroActivity : FixedFontScaleAppCompatActivity(), RetroInputView.Listener } rating.visibility = View.VISIBLE rating.reset() + rating.post { applyDisplayGeometry() } } private fun applyRetroHudSettings(rating: FrameRating) { @@ -339,6 +390,8 @@ class RetroActivity : FixedFontScaleAppCompatActivity(), RetroInputView.Listener } is GLRetroView.GLRetroEvents.SurfaceCreated -> { if (!audioEnabledSetting) retroView.audioEnabled = false + surfaceReady = true + applyDisplayGeometry() lifecycleScope.launch(Dispatchers.Default) { runCatching { diskCount = retroView.getAvailableDisks() @@ -476,7 +529,12 @@ class RetroActivity : FixedFontScaleAppCompatActivity(), RetroInputView.Listener entries += RetroMenuEntry.Action("HUD", RetroDrawerIcons.Hud, active = hudVisible) { hudVisible = !hudVisible - if (hudVisible) showHud() else frameRating?.visibility = View.GONE + if (hudVisible) { + showHud() + } else { + frameRating?.visibility = View.GONE + applyDisplayGeometry() + } persistExtra(RetroShortcuts.KEY_HUD, if (hudVisible) "1" else "0") menu.rebuild() } diff --git a/app/src/main/feature/retro/RetroInputView.kt b/app/src/main/feature/retro/RetroInputView.kt index bc014c800..662a0abed 100644 --- a/app/src/main/feature/retro/RetroInputView.kt +++ b/app/src/main/feature/retro/RetroInputView.kt @@ -125,8 +125,20 @@ class RetroInputView( buttons.clear() val width = w.toFloat() val height = h.toFloat() + if (height > width) { + layoutPortrait(width, height) + } else { + layoutLandscape(width, height) + } + } + + private fun layoutLandscape( + width: Float, + height: Float, + ) { snap = width / 100f val margin = snap * 2.5f + val bottomGap = snap * 9f val faceRadius = snap * 3f strokeWidth = max(2f, snap * 0.18f) @@ -167,7 +179,7 @@ class RetroInputView( val spread = snap * 5.5f val clusterCx = width - margin - faceRadius - spread - val clusterCy = height - snap * 6f - faceRadius - spread + val clusterCy = height - bottomGap - faceRadius - spread var clusterTop = height fun addFace( keyCode: Int, @@ -208,25 +220,94 @@ class RetroInputView( val menuW = snap * 6f if (config.hasStick) { - dpadRadius = snap * 6.5f - dpadCx = margin + dpadRadius + snap * 1f - menuButton.bounds.set(dpadCx - menuW * 0.5f, leftCursor, dpadCx + menuW * 0.5f, leftCursor + pillH) - leftCursor += pillH + trigGap - dpadCy = leftCursor + dpadRadius - leftCursor += dpadRadius * 2 + trigGap + menuButton.bounds.set( + width - margin - pillW * 3 - pillGap * 2, + pillY, + width - margin - pillW * 2 - pillGap * 2, + pillY + pillH, + ) stickRadius = snap * 7f - stickCx = dpadCx - stickCy = max(leftCursor + stickRadius, height - snap * 6f - stickRadius) + stickCx = margin + stickRadius + snap * 1f + stickCy = height - bottomGap - stickRadius + dpadRadius = snap * 6.5f + dpadCx = stickCx + dpadCy = stickCy - stickRadius - snap * 2f - dpadRadius } else { stickRadius = 0f dpadRadius = snap * 7.5f dpadCx = margin + dpadRadius - dpadCy = height - snap * 6f - dpadRadius + dpadCy = height - bottomGap - dpadRadius val menuY = max(pillY, leftCursor) menuButton.bounds.set(dpadCx - menuW * 0.5f, menuY, dpadCx + menuW * 0.5f, menuY + pillH) } } + private fun layoutPortrait( + width: Float, + height: Float, + ) { + snap = width / 100f + strokeWidth = max(2f, snap * 0.4f) + stickRadius = 0f + val zoneTop = height * 0.5f + val zoneH = height - zoneTop + val margin = snap * 5f + + if (config.hasShoulders) { + val trigW = snap * 24f + val trigH = snap * 8f + val lb = GlassButton(KeyEvent.KEYCODE_BUTTON_L1, "L", GlassShape.TRIGGER_LB, textScale = 1.3f) + lb.bounds.set(margin, zoneTop + snap * 2f, margin + trigW, zoneTop + snap * 2f + trigH) + buttons += lb + val rb = GlassButton(KeyEvent.KEYCODE_BUTTON_R1, "R", GlassShape.TRIGGER_RB, textScale = 1.3f) + rb.bounds.set(width - margin - trigW, zoneTop + snap * 2f, width - margin, zoneTop + snap * 2f + trigH) + buttons += rb + } + + val rowCy = zoneTop + zoneH * 0.45f + dpadRadius = snap * 15f + dpadCx = margin + dpadRadius + dpadCy = rowCy + + val faceRadius = snap * 8.5f + fun addFace( + keyCode: Int, + label: String, + cx: Float, + cy: Float, + ) { + val button = GlassButton(keyCode, label, GlassShape.CIRCLE) + button.bounds.set(cx - faceRadius, cy - faceRadius, cx + faceRadius, cy + faceRadius) + buttons += button + } + addFace(KeyEvent.KEYCODE_BUTTON_A, "A", width - margin - faceRadius, rowCy - faceRadius * 0.9f) + addFace( + KeyEvent.KEYCODE_BUTTON_B, + "B", + width - margin - faceRadius * 3f - snap * 2f, + rowCy + faceRadius * 0.9f, + ) + + val pillW = snap * 13f + val pillH = snap * 5.5f + val pillGap = snap * 2f + val pillY = height - margin - pillH + val select = GlassButton(KeyEvent.KEYCODE_BUTTON_SELECT, "SELECT", GlassShape.PILL, textScale = 0.75f) + select.bounds.set(width * 0.5f - pillGap * 0.5f - pillW, pillY, width * 0.5f - pillGap * 0.5f, pillY + pillH) + buttons += select + val start = GlassButton(KeyEvent.KEYCODE_BUTTON_START, "START", GlassShape.PILL, textScale = 0.75f) + start.bounds.set(width * 0.5f + pillGap * 0.5f, pillY, width * 0.5f + pillGap * 0.5f + pillW, pillY + pillH) + buttons += start + + val menuW = snap * 13f + menuButton.bounds.set( + width * 0.5f - menuW * 0.5f, + zoneTop + snap * 1.5f, + width * 0.5f + menuW * 0.5f, + zoneTop + snap * 1.5f + pillH, + ) + } + override fun onDraw(canvas: Canvas) { super.onDraw(canvas) paint.strokeWidth = strokeWidth From e9b108f02b372ed5a02cd7a6bb044ba47a8ac22c Mon Sep 17 00:00:00 2001 From: max Date: Tue, 14 Jul 2026 09:46:20 -0400 Subject: [PATCH 17/38] Cap the retro drawer height in portrait to its landscape size instead of full screen --- app/src/main/feature/retro/RetroDrawerMenu.kt | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/app/src/main/feature/retro/RetroDrawerMenu.kt b/app/src/main/feature/retro/RetroDrawerMenu.kt index 00d228b4d..8b6d84f30 100644 --- a/app/src/main/feature/retro/RetroDrawerMenu.kt +++ b/app/src/main/feature/retro/RetroDrawerMenu.kt @@ -293,7 +293,14 @@ class RetroMenuController { fun RetroDrawerMenu(controller: RetroMenuController) { val density = LocalDensity.current BoxWithConstraints(Modifier.fillMaxSize()) { - val paneScale = (maxHeight.value / 520f).coerceIn(0.78f, 1f) + val portrait = maxHeight > maxWidth + val sheetHeight = + if (portrait) { + minOf(maxWidth, maxHeight - DrawerVerticalPadding * 2) + } else { + maxHeight - DrawerVerticalPadding * 2 + } + val paneScale = (sheetHeight.value / 520f).coerceIn(0.78f, 1f) if (controller.visible) { Box( Modifier @@ -315,7 +322,7 @@ fun RetroDrawerMenu(controller: RetroMenuController) { Box( Modifier .padding(start = DrawerStartPadding, top = DrawerVerticalPadding, bottom = DrawerVerticalPadding) - .fillMaxHeight() + .height(sheetHeight) .width(DrawerWidth) .offset { androidx.compose.ui.unit.IntOffset(with(density) { sheetOffset.roundToPx() }, 0) } .clip(RoundedCornerShape(20.dp)) From 9ba206c25bc21ae515c2b5c6bb5b89975d567545 Mon Sep 17 00:00:00 2001 From: max Date: Tue, 14 Jul 2026 09:49:35 -0400 Subject: [PATCH 18/38] Rework the N64 touch layout - L sits alone at the top-left; Z moves to the top-right above R as a right-side trigger stack - Menu, Select, and Start are centered at the bottom under the game picture - D-pad stays above the joystick on the left; the joystick and the A/B pair drop lower on the screen for a more natural thumb position - A/B now send the correct N64 buttons through the core's RetroPad mapping (A as the south input, B as the west input) and sit in the real controller's arrangement with B upper-left of A --- app/src/main/feature/retro/RetroInputView.kt | 103 +++++++++++++++---- 1 file changed, 82 insertions(+), 21 deletions(-) diff --git a/app/src/main/feature/retro/RetroInputView.kt b/app/src/main/feature/retro/RetroInputView.kt index 662a0abed..1596e460a 100644 --- a/app/src/main/feature/retro/RetroInputView.kt +++ b/app/src/main/feature/retro/RetroInputView.kt @@ -136,6 +136,10 @@ class RetroInputView( width: Float, height: Float, ) { + if (config.hasStick) { + layoutN64(width, height) + return + } snap = width / 100f val margin = snap * 2.5f val bottomGap = snap * 9f @@ -219,27 +223,84 @@ class RetroInputView( buttons += select val menuW = snap * 6f - if (config.hasStick) { - menuButton.bounds.set( - width - margin - pillW * 3 - pillGap * 2, - pillY, - width - margin - pillW * 2 - pillGap * 2, - pillY + pillH, - ) - stickRadius = snap * 7f - stickCx = margin + stickRadius + snap * 1f - stickCy = height - bottomGap - stickRadius - dpadRadius = snap * 6.5f - dpadCx = stickCx - dpadCy = stickCy - stickRadius - snap * 2f - dpadRadius - } else { - stickRadius = 0f - dpadRadius = snap * 7.5f - dpadCx = margin + dpadRadius - dpadCy = height - bottomGap - dpadRadius - val menuY = max(pillY, leftCursor) - menuButton.bounds.set(dpadCx - menuW * 0.5f, menuY, dpadCx + menuW * 0.5f, menuY + pillH) - } + stickRadius = 0f + dpadRadius = snap * 7.5f + dpadCx = margin + dpadRadius + dpadCy = height - bottomGap - dpadRadius + val menuY = max(pillY, leftCursor) + menuButton.bounds.set(dpadCx - menuW * 0.5f, menuY, dpadCx + menuW * 0.5f, menuY + pillH) + } + + private fun layoutN64( + width: Float, + height: Float, + ) { + snap = width / 100f + strokeWidth = max(2f, snap * 0.18f) + val margin = snap * 2.5f + val bottomGap = snap * 6.5f + val trigW = snap * 10.4f + val trigH = snap * 5.2f + val trigGap = snap * 1.5f + + val lb = GlassButton(KeyEvent.KEYCODE_BUTTON_L1, "L", GlassShape.TRIGGER_LB, textScale = 1.3f) + lb.bounds.set(margin, margin, margin + trigW, margin + trigH) + buttons += lb + val z = + GlassButton(KeyEvent.KEYCODE_BUTTON_L2, config.leftTriggerLabel, GlassShape.TRIGGER_RT, textScale = 1.3f) + z.bounds.set(width - margin - trigW, margin, width - margin, margin + trigH) + buttons += z + val rb = GlassButton(KeyEvent.KEYCODE_BUTTON_R1, "R", GlassShape.TRIGGER_RB, textScale = 1.3f) + rb.bounds.set( + width - margin - trigW, + margin + trigH + trigGap, + width - margin, + margin + trigH * 2 + trigGap, + ) + buttons += rb + + val faceRadius = snap * 3f + val spread = snap * 5.5f + val clusterCx = width - margin - faceRadius - spread + val clusterCy = height - bottomGap - faceRadius - spread + val bButton = GlassButton(KeyEvent.KEYCODE_BUTTON_Y, "B", GlassShape.CIRCLE) + bButton.bounds.set( + clusterCx - faceRadius * 2.1f, + clusterCy + spread * 0.5f - faceRadius * 2.1f, + clusterCx - faceRadius * 0.1f, + clusterCy + spread * 0.5f - faceRadius * 0.1f, + ) + buttons += bButton + val aButton = GlassButton(KeyEvent.KEYCODE_BUTTON_B, "A", GlassShape.CIRCLE) + aButton.bounds.set( + clusterCx + faceRadius * 0.1f, + clusterCy + spread * 0.5f + faceRadius * 0.1f, + clusterCx + faceRadius * 2.1f, + clusterCy + spread * 0.5f + faceRadius * 2.1f, + ) + buttons += aButton + + val pillW = snap * 6f + val pillH = snap * 3f + val pillGap = snap * 1.2f + val pillY = height - snap * 2.5f - pillH + var pillX = (width - pillW * 3f - pillGap * 2f) * 0.5f + menuButton.bounds.set(pillX, pillY, pillX + pillW, pillY + pillH) + pillX += pillW + pillGap + val select = GlassButton(KeyEvent.KEYCODE_BUTTON_SELECT, "SELECT", GlassShape.PILL, textScale = 0.75f) + select.bounds.set(pillX, pillY, pillX + pillW, pillY + pillH) + buttons += select + pillX += pillW + pillGap + val start = GlassButton(KeyEvent.KEYCODE_BUTTON_START, "START", GlassShape.PILL, textScale = 0.75f) + start.bounds.set(pillX, pillY, pillX + pillW, pillY + pillH) + buttons += start + + stickRadius = snap * 7f + stickCx = margin + stickRadius + snap * 1f + stickCy = height - bottomGap - stickRadius + dpadRadius = snap * 6.5f + dpadCx = stickCx + dpadCy = stickCy - stickRadius - snap * 2f - dpadRadius } private fun layoutPortrait( From a3513971729462b00e9e78f1c0b0d01c763684c4 Mon Sep 17 00:00:00 2001 From: max Date: Tue, 14 Jul 2026 09:56:37 -0400 Subject: [PATCH 19/38] Add N64 C buttons to the touch overlay - Four glass C buttons in the real controller's diamond arrangement sit on the right side between the Z/R trigger stack and the A/B pair, tinted the N64's C-button yellow with directional arrow glyphs - They drive the libretro right analog stick, which Mupen64Plus maps to the C buttons, with multi-touch support and full release handling --- app/src/main/feature/retro/RetroActivity.kt | 8 ++ app/src/main/feature/retro/RetroInputView.kt | 108 +++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/app/src/main/feature/retro/RetroActivity.kt b/app/src/main/feature/retro/RetroActivity.kt index d6f80c9e4..fffe3e0bb 100644 --- a/app/src/main/feature/retro/RetroActivity.kt +++ b/app/src/main/feature/retro/RetroActivity.kt @@ -704,6 +704,14 @@ class RetroActivity : FixedFontScaleAppCompatActivity(), RetroInputView.Listener retroView.sendMotionEvent(GLRetroView.MOTION_SOURCE_ANALOG_LEFT, x, y, 0) } + override fun onRightStick( + x: Float, + y: Float, + ) { + if (!retroReady || menu.visible) return + retroView.sendMotionEvent(GLRetroView.MOTION_SOURCE_ANALOG_RIGHT, x, y, 0) + } + override fun onMenu() { runOnUiThread { openMenu() } } diff --git a/app/src/main/feature/retro/RetroInputView.kt b/app/src/main/feature/retro/RetroInputView.kt index 1596e460a..5ae681bc2 100644 --- a/app/src/main/feature/retro/RetroInputView.kt +++ b/app/src/main/feature/retro/RetroInputView.kt @@ -37,6 +37,11 @@ class RetroInputView( y: Float, ) + fun onRightStick( + x: Float, + y: Float, + ) + fun onMenu() } @@ -50,6 +55,13 @@ class RetroInputView( val bounds: RectF = RectF(), ) + private class CButton( + val dx: Float, + val dy: Float, + val glyph: String, + val bounds: RectF = RectF(), + ) + private data class OverlayConfig( val hasXY: Boolean, val hasShoulders: Boolean, @@ -79,8 +91,16 @@ class RetroInputView( } private val buttons = mutableListOf() + private val cButtons = mutableListOf() private val menuButton = GlassButton(0, "MENU", GlassShape.PILL, textScale = 0.75f) private var snap = 0f + private var cStickX = 0f + private var cStickY = 0f + + private val cAccentStroke = Color.argb(150, 255, 210, 90) + private val cAccentPressedStroke = Color.argb(220, 255, 214, 96) + private val cAccentPressedFill = Color.argb(60, 255, 210, 90) + private val cAccentText = Color.argb(255, 255, 214, 96) private var dpadCx = 0f private var dpadCy = 0f @@ -123,6 +143,7 @@ class RetroInputView( ) { super.onSizeChanged(w, h, oldw, oldh) buttons.clear() + cButtons.clear() val width = w.toFloat() val height = h.toFloat() if (height > width) { @@ -301,6 +322,28 @@ class RetroInputView( dpadRadius = snap * 6.5f dpadCx = stickCx dpadCy = stickCy - stickRadius - snap * 2f - dpadRadius + + val cRadius = snap * 2.4f + val cSpread = snap * 3.6f + val cCx = clusterCx + val topOfFaces = clusterCy + spread * 0.5f - faceRadius * 2.1f + val bottomOfTriggers = margin + trigH * 2 + trigGap + val cCy = (bottomOfTriggers + topOfFaces) * 0.5f + fun addC( + dx: Float, + dy: Float, + glyph: String, + x: Float, + y: Float, + ) { + val c = CButton(dx, dy, glyph) + c.bounds.set(x - cRadius, y - cRadius, x + cRadius, y + cRadius) + cButtons += c + } + addC(0f, -1f, "▲", cCx, cCy - cSpread) + addC(0f, 1f, "▼", cCx, cCy + cSpread) + addC(-1f, 0f, "◀", cCx - cSpread, cCy) + addC(1f, 0f, "▶", cCx + cSpread, cCy) } private fun layoutPortrait( @@ -377,9 +420,49 @@ class RetroInputView( drawDpad(canvas) if (config.hasStick) drawStick(canvas) buttons.forEach { drawGlassButton(canvas, it, pressedButtons.contains(it.keyCode)) } + cButtons.forEach { drawCButton(canvas, it) } drawGlassButton(canvas, menuButton, menuLatched) } + private fun drawCButton( + canvas: Canvas, + button: CButton, + ) { + val b = button.bounds + val pressed = + (button.dx != 0f && cStickX == button.dx) || (button.dy != 0f && cStickY == button.dy) + paint.shader = null + paint.style = Paint.Style.FILL + paint.color = fillColor + canvas.drawCircle(b.centerX(), b.centerY(), b.width() * 0.5f, paint) + if (pressed) { + paint.color = cAccentPressedFill + canvas.drawCircle(b.centerX(), b.centerY(), b.width() * 0.5f, paint) + } + paint.shader = + RadialGradient( + b.centerX(), + b.centerY(), + b.width() * 0.5f, + Color.argb(0, 0, 0, 0), + Color.argb(glassEdgeAlpha, 0, 0, 0), + Shader.TileMode.CLAMP, + ) + canvas.drawCircle(b.centerX(), b.centerY(), b.width() * 0.5f, paint) + paint.shader = null + paint.style = Paint.Style.STROKE + paint.color = if (pressed) cAccentPressedStroke else cAccentStroke + canvas.drawCircle(b.centerX(), b.centerY(), b.width() * 0.5f, paint) + paint.style = Paint.Style.FILL + paint.color = cAccentText + paint.textAlign = Paint.Align.CENTER + paint.isFakeBoldText = true + paint.textSize = b.width() * 0.5f + val textY = b.centerY() - (paint.descent() + paint.ascent()) * 0.5f + canvas.drawText(button.glyph, b.centerX(), textY, paint) + paint.isFakeBoldText = false + } + private fun buildShapePath(button: GlassButton) { val b = button.bounds when (button.shape) { @@ -533,6 +616,11 @@ class RetroInputView( stickY = 0f listener.onStick(0f, 0f) } + if (cStickX != 0f || cStickY != 0f) { + cStickX = 0f + cStickY = 0f + listener.onRightStick(0f, 0f) + } menuLatched = false invalidate() } @@ -577,6 +665,8 @@ class RetroInputView( val newPressed = HashSet() var newDpadX = 0f var newDpadY = 0f + var newCX = 0f + var newCY = 0f var menuTouched = false var stickSeen = false var newStickX = stickX @@ -621,6 +711,18 @@ class RetroInputView( continue } + var cHit = false + for (c in cButtons) { + val reach = c.bounds.width() * 0.5f + snap * 1.2f + if (hypot(x - c.bounds.centerX(), y - c.bounds.centerY()) <= reach) { + if (c.dx != 0f) newCX = c.dx + if (c.dy != 0f) newCY = c.dy + cHit = true + break + } + } + if (cHit) continue + for (button in buttons) { if (hitButton(button, x, y)) { newPressed.add(button.keyCode) @@ -630,6 +732,12 @@ class RetroInputView( } } + if (newCX != cStickX || newCY != cStickY) { + cStickX = newCX + cStickY = newCY + listener.onRightStick(cStickX, cStickY) + } + if (!stickSeen && stickPointerId != -1) { stickPointerId = -1 newStickX = 0f From 3f5bc87dd68f26ec72b134bda3db759c79b68ec3 Mon Sep 17 00:00:00 2001 From: max Date: Tue, 14 Jul 2026 10:03:31 -0400 Subject: [PATCH 20/38] Tune N64 overlay spacing - C-button diamond shifts slightly right and down into the open space between the R trigger and the face pair, clamped so it never crowds B - B and A move closer together and the pair drops slightly lower; the left joystick drops a touch less so it stays in easy thumb reach --- app/src/main/feature/retro/RetroInputView.kt | 33 +++++++++----------- 1 file changed, 15 insertions(+), 18 deletions(-) diff --git a/app/src/main/feature/retro/RetroInputView.kt b/app/src/main/feature/retro/RetroInputView.kt index 5ae681bc2..56b6f52c9 100644 --- a/app/src/main/feature/retro/RetroInputView.kt +++ b/app/src/main/feature/retro/RetroInputView.kt @@ -259,7 +259,6 @@ class RetroInputView( snap = width / 100f strokeWidth = max(2f, snap * 0.18f) val margin = snap * 2.5f - val bottomGap = snap * 6.5f val trigW = snap * 10.4f val trigH = snap * 5.2f val trigGap = snap * 1.5f @@ -283,22 +282,16 @@ class RetroInputView( val faceRadius = snap * 3f val spread = snap * 5.5f val clusterCx = width - margin - faceRadius - spread - val clusterCy = height - bottomGap - faceRadius - spread + val clusterCy = height - snap * 4.5f - faceRadius - spread + val bCx = clusterCx - faceRadius * 0.9f + val bCy = clusterCy + spread * 0.5f - faceRadius * 0.9f val bButton = GlassButton(KeyEvent.KEYCODE_BUTTON_Y, "B", GlassShape.CIRCLE) - bButton.bounds.set( - clusterCx - faceRadius * 2.1f, - clusterCy + spread * 0.5f - faceRadius * 2.1f, - clusterCx - faceRadius * 0.1f, - clusterCy + spread * 0.5f - faceRadius * 0.1f, - ) + bButton.bounds.set(bCx - faceRadius, bCy - faceRadius, bCx + faceRadius, bCy + faceRadius) buttons += bButton + val aCx = clusterCx + faceRadius * 0.9f + val aCy = clusterCy + spread * 0.5f + faceRadius * 0.9f val aButton = GlassButton(KeyEvent.KEYCODE_BUTTON_B, "A", GlassShape.CIRCLE) - aButton.bounds.set( - clusterCx + faceRadius * 0.1f, - clusterCy + spread * 0.5f + faceRadius * 0.1f, - clusterCx + faceRadius * 2.1f, - clusterCy + spread * 0.5f + faceRadius * 2.1f, - ) + aButton.bounds.set(aCx - faceRadius, aCy - faceRadius, aCx + faceRadius, aCy + faceRadius) buttons += aButton val pillW = snap * 6f @@ -318,17 +311,21 @@ class RetroInputView( stickRadius = snap * 7f stickCx = margin + stickRadius + snap * 1f - stickCy = height - bottomGap - stickRadius + stickCy = height - snap * 5.5f - stickRadius dpadRadius = snap * 6.5f dpadCx = stickCx dpadCy = stickCy - stickRadius - snap * 2f - dpadRadius val cRadius = snap * 2.4f val cSpread = snap * 3.6f - val cCx = clusterCx - val topOfFaces = clusterCy + spread * 0.5f - faceRadius * 2.1f + val cCx = clusterCx + snap * 2f + val topOfFaces = bCy - faceRadius val bottomOfTriggers = margin + trigH * 2 + trigGap - val cCy = (bottomOfTriggers + topOfFaces) * 0.5f + val cCy = + min( + (bottomOfTriggers + topOfFaces) * 0.5f + snap * 2f, + topOfFaces - snap * 1.5f - cSpread - cRadius, + ) fun addC( dx: Float, dy: Float, From ed3f95d0066069c3bf2b58ec5459e52e55bb7932 Mon Sep 17 00:00:00 2001 From: max Date: Tue, 14 Jul 2026 11:06:47 -0400 Subject: [PATCH 21/38] Vendor LibretroDroid, fix hardware-core shader switching, add SGSR upscaling - LibretroDroid 0.14.0 is now a source module (:libretrodroid) with Oboe and libretro-common as pinned submodules, replacing the JitPack binary so the renderer can be patched and extended; NDK 27 needed explicit includes, and the module builds arm64-v8a with 16KB page alignment Emulator/renderer sources and credit: - LibretroDroid by Filippo Scognamiglio (GPL-3.0), vendored unmodified from https://github.com/Swordfish90/LibretroDroid at tag 0.14.0 except for the patches described here, with its license and headers intact - google/oboe and libretro/libretro-common submodules pinned to the exact commits the upstream 0.14.0 build used - Snapdragon Game Super Resolution 1.0 by Qualcomm Innovation Center, Inc. (BSD-3-Clause), adapted from sgsr1_shader_mobile.frag at https://github.com/SnapdragonGameStudios/snapdragon-gsr with the license preserved in libretrodroid/src/main/cpp/SGSR_LICENSE Renderer fix: - Video effects on hardware-rendered cores (N64, PSX hardware renderers) blacked out because a shader change rebuilt the framebuffer the core was actively rendering into; the main framebuffer now survives shader-only changes and is recreated solely on resolution changes, with texture filtering updated in place SGSR: - New SHADER_UPSCALE_SGSR chain entry in the shader manager (GLSL ES 3.10 single pass), exposed through ShaderConfig.SGSR and available as an "SGSR" video filter for every retro console in both the in-game drawer and the shortcut settings dialog Drawer: - The Display tab now lays filters out two per row in a scrollable grid, with controller navigation moving through the grid in both axes --- .gitmodules | 6 + app/build.gradle | 2 +- app/src/main/feature/retro/RetroActivity.kt | 5 +- app/src/main/feature/retro/RetroDrawerMenu.kt | 124 +- .../main/feature/retro/RetroGameSettings.kt | 4 +- build.gradle | 1 + gradle/libs.versions.toml | 1 + libretrodroid/.gitignore | 1 + libretrodroid/build.gradle | 56 + libretrodroid/consumer-rules.pro | 0 libretrodroid/proguard-rules.pro | 21 + libretrodroid/src/main/AndroidManifest.xml | 4 + libretrodroid/src/main/cpp/CMakeLists.txt | 102 + libretrodroid/src/main/cpp/SGSR_LICENSE | 29 + libretrodroid/src/main/cpp/audio.cpp | 171 ++ libretrodroid/src/main/cpp/audio.h | 96 + libretrodroid/src/main/cpp/core.cpp | 80 + libretrodroid/src/main/cpp/core.h | 67 + libretrodroid/src/main/cpp/environment.cpp | 482 +++++ libretrodroid/src/main/cpp/environment.h | 167 ++ libretrodroid/src/main/cpp/errorcodes.cpp | 30 + libretrodroid/src/main/cpp/errorcodes.h | 30 + libretrodroid/src/main/cpp/fpssync.cpp | 64 + libretrodroid/src/main/cpp/fpssync.h | 55 + libretrodroid/src/main/cpp/immersivemode.cpp | 286 +++ libretrodroid/src/main/cpp/immersivemode.h | 200 ++ libretrodroid/src/main/cpp/input.cpp | 227 +++ libretrodroid/src/main/cpp/input.h | 73 + .../src/main/cpp/libretro/libretro-common | 1 + libretrodroid/src/main/cpp/libretrodroid.cpp | 654 ++++++ libretrodroid/src/main/cpp/libretrodroid.h | 191 ++ .../src/main/cpp/libretrodroidjni.cpp | 611 ++++++ libretrodroid/src/main/cpp/libretrodroidjni.h | 56 + libretrodroid/src/main/cpp/log.h | 89 + .../src/main/cpp/microphone/microphone.cpp | 99 + .../src/main/cpp/microphone/microphone.h | 61 + .../cpp/microphone/microphoneinterface.cpp | 84 + .../main/cpp/microphone/microphoneinterface.h | 48 + libretrodroid/src/main/cpp/oboe | 1 + .../cpp/renderers/es2/imagerendereres2.cpp | 130 ++ .../main/cpp/renderers/es2/imagerendereres2.h | 64 + .../src/main/cpp/renderers/es3/es3utils.cpp | 123 ++ .../src/main/cpp/renderers/es3/es3utils.h | 65 + .../cpp/renderers/es3/framebufferrenderer.cpp | 123 ++ .../cpp/renderers/es3/framebufferrenderer.h | 68 + .../cpp/renderers/es3/imagerendereres3.cpp | 153 ++ .../main/cpp/renderers/es3/imagerendereres3.h | 72 + .../src/main/cpp/renderers/renderer.cpp | 26 + .../src/main/cpp/renderers/renderer.h | 58 + .../main/cpp/resamplers/linearresampler.cpp | 43 + .../src/main/cpp/resamplers/linearresampler.h | 34 + .../src/main/cpp/resamplers/resampler.h | 32 + .../src/main/cpp/resamplers/sincresampler.cpp | 59 + .../src/main/cpp/resamplers/sincresampler.h | 41 + libretrodroid/src/main/cpp/rumble.cpp | 51 + libretrodroid/src/main/cpp/rumble.h | 40 + libretrodroid/src/main/cpp/rumblestate.cpp | 26 + libretrodroid/src/main/cpp/rumblestate.h | 34 + libretrodroid/src/main/cpp/shadermanager.cpp | 1749 +++++++++++++++++ libretrodroid/src/main/cpp/shadermanager.h | 107 + .../src/main/cpp/utils/javautils.cpp | 105 + libretrodroid/src/main/cpp/utils/javautils.h | 44 + .../src/main/cpp/utils/jnistring.cpp | 35 + libretrodroid/src/main/cpp/utils/jnistring.h | 42 + .../main/cpp/utils/libretrodroidexception.cpp | 22 + .../main/cpp/utils/libretrodroidexception.h | 36 + libretrodroid/src/main/cpp/utils/rect.cpp | 25 + libretrodroid/src/main/cpp/utils/rect.h | 41 + libretrodroid/src/main/cpp/utils/utils.cpp | 63 + libretrodroid/src/main/cpp/utils/utils.h | 40 + libretrodroid/src/main/cpp/vfs/fdwrapper.cpp | 29 + libretrodroid/src/main/cpp/vfs/fdwrapper.h | 44 + libretrodroid/src/main/cpp/vfs/vfs.cpp | 164 ++ libretrodroid/src/main/cpp/vfs/vfs.h | 79 + libretrodroid/src/main/cpp/vfs/vfsfile.cpp | 34 + libretrodroid/src/main/cpp/vfs/vfsfile.h | 48 + libretrodroid/src/main/cpp/video.cpp | 319 +++ libretrodroid/src/main/cpp/video.h | 119 ++ libretrodroid/src/main/cpp/videolayout.cpp | 261 +++ libretrodroid/src/main/cpp/videolayout.h | 167 ++ .../com/swordfish/libretrodroid/Controller.kt | 3 + .../libretrodroid/DetachedVirtualFile.kt | 3 + .../swordfish/libretrodroid/GLRetroShader.kt | 20 + .../swordfish/libretrodroid/GLRetroView.kt | 524 +++++ .../libretrodroid/GLRetroViewData.kt | 44 + .../swordfish/libretrodroid/ImmersiveMode.kt | 10 + .../com/swordfish/libretrodroid/KtUtils.kt | 54 + .../libretrodroid/LibretroDroid.java | 145 ++ .../swordfish/libretrodroid/RetroException.kt | 5 + .../swordfish/libretrodroid/RumbleEvent.kt | 6 + .../swordfish/libretrodroid/ShaderConfig.kt | 66 + .../com/swordfish/libretrodroid/Variable.kt | 3 + .../swordfish/libretrodroid/VirtualFile.kt | 5 + .../libretrodroid/gamepad/GamepadsManager.kt | 57 + libretrodroid/src/main/res/values/strings.xml | 3 + settings.gradle | 1 + 96 files changed, 9986 insertions(+), 57 deletions(-) create mode 100644 libretrodroid/.gitignore create mode 100644 libretrodroid/build.gradle create mode 100644 libretrodroid/consumer-rules.pro create mode 100644 libretrodroid/proguard-rules.pro create mode 100644 libretrodroid/src/main/AndroidManifest.xml create mode 100644 libretrodroid/src/main/cpp/CMakeLists.txt create mode 100644 libretrodroid/src/main/cpp/SGSR_LICENSE create mode 100644 libretrodroid/src/main/cpp/audio.cpp create mode 100644 libretrodroid/src/main/cpp/audio.h create mode 100644 libretrodroid/src/main/cpp/core.cpp create mode 100644 libretrodroid/src/main/cpp/core.h create mode 100644 libretrodroid/src/main/cpp/environment.cpp create mode 100644 libretrodroid/src/main/cpp/environment.h create mode 100644 libretrodroid/src/main/cpp/errorcodes.cpp create mode 100644 libretrodroid/src/main/cpp/errorcodes.h create mode 100644 libretrodroid/src/main/cpp/fpssync.cpp create mode 100644 libretrodroid/src/main/cpp/fpssync.h create mode 100644 libretrodroid/src/main/cpp/immersivemode.cpp create mode 100644 libretrodroid/src/main/cpp/immersivemode.h create mode 100644 libretrodroid/src/main/cpp/input.cpp create mode 100644 libretrodroid/src/main/cpp/input.h create mode 160000 libretrodroid/src/main/cpp/libretro/libretro-common create mode 100644 libretrodroid/src/main/cpp/libretrodroid.cpp create mode 100644 libretrodroid/src/main/cpp/libretrodroid.h create mode 100644 libretrodroid/src/main/cpp/libretrodroidjni.cpp create mode 100644 libretrodroid/src/main/cpp/libretrodroidjni.h create mode 100644 libretrodroid/src/main/cpp/log.h create mode 100644 libretrodroid/src/main/cpp/microphone/microphone.cpp create mode 100644 libretrodroid/src/main/cpp/microphone/microphone.h create mode 100644 libretrodroid/src/main/cpp/microphone/microphoneinterface.cpp create mode 100644 libretrodroid/src/main/cpp/microphone/microphoneinterface.h create mode 160000 libretrodroid/src/main/cpp/oboe create mode 100644 libretrodroid/src/main/cpp/renderers/es2/imagerendereres2.cpp create mode 100644 libretrodroid/src/main/cpp/renderers/es2/imagerendereres2.h create mode 100644 libretrodroid/src/main/cpp/renderers/es3/es3utils.cpp create mode 100644 libretrodroid/src/main/cpp/renderers/es3/es3utils.h create mode 100644 libretrodroid/src/main/cpp/renderers/es3/framebufferrenderer.cpp create mode 100644 libretrodroid/src/main/cpp/renderers/es3/framebufferrenderer.h create mode 100644 libretrodroid/src/main/cpp/renderers/es3/imagerendereres3.cpp create mode 100644 libretrodroid/src/main/cpp/renderers/es3/imagerendereres3.h create mode 100644 libretrodroid/src/main/cpp/renderers/renderer.cpp create mode 100644 libretrodroid/src/main/cpp/renderers/renderer.h create mode 100644 libretrodroid/src/main/cpp/resamplers/linearresampler.cpp create mode 100644 libretrodroid/src/main/cpp/resamplers/linearresampler.h create mode 100644 libretrodroid/src/main/cpp/resamplers/resampler.h create mode 100644 libretrodroid/src/main/cpp/resamplers/sincresampler.cpp create mode 100644 libretrodroid/src/main/cpp/resamplers/sincresampler.h create mode 100644 libretrodroid/src/main/cpp/rumble.cpp create mode 100644 libretrodroid/src/main/cpp/rumble.h create mode 100644 libretrodroid/src/main/cpp/rumblestate.cpp create mode 100644 libretrodroid/src/main/cpp/rumblestate.h create mode 100644 libretrodroid/src/main/cpp/shadermanager.cpp create mode 100644 libretrodroid/src/main/cpp/shadermanager.h create mode 100644 libretrodroid/src/main/cpp/utils/javautils.cpp create mode 100644 libretrodroid/src/main/cpp/utils/javautils.h create mode 100644 libretrodroid/src/main/cpp/utils/jnistring.cpp create mode 100644 libretrodroid/src/main/cpp/utils/jnistring.h create mode 100644 libretrodroid/src/main/cpp/utils/libretrodroidexception.cpp create mode 100644 libretrodroid/src/main/cpp/utils/libretrodroidexception.h create mode 100644 libretrodroid/src/main/cpp/utils/rect.cpp create mode 100644 libretrodroid/src/main/cpp/utils/rect.h create mode 100644 libretrodroid/src/main/cpp/utils/utils.cpp create mode 100644 libretrodroid/src/main/cpp/utils/utils.h create mode 100644 libretrodroid/src/main/cpp/vfs/fdwrapper.cpp create mode 100644 libretrodroid/src/main/cpp/vfs/fdwrapper.h create mode 100644 libretrodroid/src/main/cpp/vfs/vfs.cpp create mode 100644 libretrodroid/src/main/cpp/vfs/vfs.h create mode 100644 libretrodroid/src/main/cpp/vfs/vfsfile.cpp create mode 100644 libretrodroid/src/main/cpp/vfs/vfsfile.h create mode 100644 libretrodroid/src/main/cpp/video.cpp create mode 100644 libretrodroid/src/main/cpp/video.h create mode 100644 libretrodroid/src/main/cpp/videolayout.cpp create mode 100644 libretrodroid/src/main/cpp/videolayout.h create mode 100644 libretrodroid/src/main/java/com/swordfish/libretrodroid/Controller.kt create mode 100644 libretrodroid/src/main/java/com/swordfish/libretrodroid/DetachedVirtualFile.kt create mode 100644 libretrodroid/src/main/java/com/swordfish/libretrodroid/GLRetroShader.kt create mode 100644 libretrodroid/src/main/java/com/swordfish/libretrodroid/GLRetroView.kt create mode 100644 libretrodroid/src/main/java/com/swordfish/libretrodroid/GLRetroViewData.kt create mode 100644 libretrodroid/src/main/java/com/swordfish/libretrodroid/ImmersiveMode.kt create mode 100644 libretrodroid/src/main/java/com/swordfish/libretrodroid/KtUtils.kt create mode 100644 libretrodroid/src/main/java/com/swordfish/libretrodroid/LibretroDroid.java create mode 100644 libretrodroid/src/main/java/com/swordfish/libretrodroid/RetroException.kt create mode 100644 libretrodroid/src/main/java/com/swordfish/libretrodroid/RumbleEvent.kt create mode 100644 libretrodroid/src/main/java/com/swordfish/libretrodroid/ShaderConfig.kt create mode 100644 libretrodroid/src/main/java/com/swordfish/libretrodroid/Variable.kt create mode 100644 libretrodroid/src/main/java/com/swordfish/libretrodroid/VirtualFile.kt create mode 100644 libretrodroid/src/main/java/com/swordfish/libretrodroid/gamepad/GamepadsManager.kt create mode 100644 libretrodroid/src/main/res/values/strings.xml diff --git a/.gitmodules b/.gitmodules index e1ec52737..4bdfced71 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,9 @@ [submodule "app/src/main/cpp/adrenotools"] path = app/src/main/cpp/adrenotools url = https://github.com/Pipetto-crypto/libadrenotools.git +[submodule "libretrodroid/src/main/cpp/oboe"] + path = libretrodroid/src/main/cpp/oboe + url = https://github.com/google/oboe +[submodule "libretrodroid/src/main/cpp/libretro/libretro-common"] + path = libretrodroid/src/main/cpp/libretro/libretro-common + url = https://github.com/libretro/libretro-common diff --git a/app/build.gradle b/app/build.gradle index 76810113f..768c1eef1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -281,7 +281,7 @@ dependencies { implementation libs.workRuntimeKtx - implementation 'com.github.Swordfish90:libretrodroid:0.14.0' + implementation project(':libretrodroid') } spotless { diff --git a/app/src/main/feature/retro/RetroActivity.kt b/app/src/main/feature/retro/RetroActivity.kt index fffe3e0bb..2d3a2ac4e 100644 --- a/app/src/main/feature/retro/RetroActivity.kt +++ b/app/src/main/feature/retro/RetroActivity.kt @@ -50,8 +50,8 @@ class RetroActivity : FixedFontScaleAppCompatActivity(), RetroInputView.Listener const val EXTRA_HUD = "retro_hud" const val EXTRA_VARIABLES = "retro_variables" - private val SHADER_KEYS = listOf("default", "crt", "lcd", "sharp") - private val SHADER_LABELS = listOf("Default", "CRT", "LCD", "Sharp") + private val SHADER_KEYS = listOf("default", "sgsr", "crt", "lcd", "sharp") + private val SHADER_LABELS = listOf("Default", "SGSR", "CRT", "LCD", "Sharp") } private lateinit var retroView: GLRetroView @@ -426,6 +426,7 @@ class RetroActivity : FixedFontScaleAppCompatActivity(), RetroInputView.Listener private fun shaderFromKey(value: String?): ShaderConfig = when (value?.lowercase()) { + "sgsr" -> ShaderConfig.SGSR "crt" -> ShaderConfig.CRT "lcd" -> ShaderConfig.LCD "sharp" -> ShaderConfig.Sharp diff --git a/app/src/main/feature/retro/RetroDrawerMenu.kt b/app/src/main/feature/retro/RetroDrawerMenu.kt index 8b6d84f30..3c936fd3d 100644 --- a/app/src/main/feature/retro/RetroDrawerMenu.kt +++ b/app/src/main/feature/retro/RetroDrawerMenu.kt @@ -171,7 +171,13 @@ class RetroMenuController { var entriesProvider: ((RetroPane?) -> List)? = null var bottomProvider: (() -> List)? = null - val gridColumns: Int get() = if (pane == null) 3 else 1 + val gridColumns: Int + get() = + when (pane) { + null -> 3 + RetroPane.DISPLAY -> 2 + else -> 1 + } fun open() { pane = null @@ -247,18 +253,18 @@ class RetroMenuController { when (keyCode) { KeyEvent.KEYCODE_DPAD_UP -> when (region) { - 1 -> if (pane == null) moveContent(-gridColumns) else moveContent(-1) + 1 -> moveContent(-gridColumns) 2 -> region = 1 } KeyEvent.KEYCODE_DPAD_DOWN -> when (region) { 0 -> if (entries.isNotEmpty()) region = 1 - 1 -> if (pane == null) moveContent(gridColumns) else moveContent(1) + 1 -> moveContent(gridColumns) } KeyEvent.KEYCODE_DPAD_LEFT -> when (region) { 0 -> railIndex = (railIndex - 1 + tabs.size) % tabs.size - 1 -> if (pane == null) moveContent(-1) else activate(-1) + 1 -> if (gridColumns > 1) moveContent(-1) else activate(-1) 2 -> if (bottomEntries.isNotEmpty()) { bottomIndex = (bottomIndex - 1 + bottomEntries.size) % bottomEntries.size } @@ -266,7 +272,7 @@ class RetroMenuController { KeyEvent.KEYCODE_DPAD_RIGHT -> when (region) { 0 -> railIndex = (railIndex + 1) % tabs.size - 1 -> if (pane == null) moveContent(1) else activate(1) + 1 -> if (gridColumns > 1) moveContent(1) else activate(1) 2 -> if (bottomEntries.isNotEmpty()) { bottomIndex = (bottomIndex + 1) % bottomEntries.size } @@ -679,6 +685,7 @@ private fun RetroPaneList( controller: RetroMenuController, paneScale: Float, ) { + val columns = controller.gridColumns.coerceAtLeast(1) Column( modifier = Modifier @@ -687,53 +694,66 @@ private fun RetroPaneList( .padding(horizontal = (10f * paneScale).dp, vertical = (10f * paneScale).dp), verticalArrangement = Arrangement.spacedBy((8f * paneScale).dp), ) { - controller.entries.forEachIndexed { index, entry -> - val highlighted = - controller.controllerActive && - controller.region == 1 && - controller.contentIndex == index - when (entry) { - is RetroMenuEntry.Toggle -> - RetroBooleanRow( - entry = entry, - highlighted = highlighted, - paneScale = paneScale, - onClick = { - controller.contentIndex = index - entry.onChange(!entry.checked) - }, - ) - is RetroMenuEntry.Choice -> - RetroChoiceRow( - entry = entry, - highlighted = highlighted, - paneScale = paneScale, - onClick = { - controller.contentIndex = index - entry.onCycle(1) - }, - ) - is RetroMenuEntry.Radio -> - RetroRadioRow( - entry = entry, - highlighted = highlighted, - paneScale = paneScale, - onClick = { - controller.contentIndex = index - entry.onSelect() - }, - ) - is RetroMenuEntry.Action -> - RetroActionCard( - entry = entry, - highlighted = highlighted, - paneScale = paneScale, - modifier = Modifier.fillMaxWidth().height((56f * paneScale).dp), - onClick = { - controller.contentIndex = index - entry.onClick() - }, - ) + controller.entries.chunked(columns).forEachIndexed { rowIndex, rowEntries -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy((8f * paneScale).dp), + ) { + rowEntries.forEachIndexed { colIndex, entry -> + val index = rowIndex * columns + colIndex + val highlighted = + controller.controllerActive && + controller.region == 1 && + controller.contentIndex == index + Box(Modifier.weight(1f)) { + when (entry) { + is RetroMenuEntry.Toggle -> + RetroBooleanRow( + entry = entry, + highlighted = highlighted, + paneScale = paneScale, + onClick = { + controller.contentIndex = index + entry.onChange(!entry.checked) + }, + ) + is RetroMenuEntry.Choice -> + RetroChoiceRow( + entry = entry, + highlighted = highlighted, + paneScale = paneScale, + onClick = { + controller.contentIndex = index + entry.onCycle(1) + }, + ) + is RetroMenuEntry.Radio -> + RetroRadioRow( + entry = entry, + highlighted = highlighted, + paneScale = paneScale, + onClick = { + controller.contentIndex = index + entry.onSelect() + }, + ) + is RetroMenuEntry.Action -> + RetroActionCard( + entry = entry, + highlighted = highlighted, + paneScale = paneScale, + modifier = Modifier.fillMaxWidth().height((56f * paneScale).dp), + onClick = { + controller.contentIndex = index + entry.onClick() + }, + ) + } + } + } + repeat(columns - rowEntries.size) { + Spacer(Modifier.weight(1f)) + } } } } diff --git a/app/src/main/feature/retro/RetroGameSettings.kt b/app/src/main/feature/retro/RetroGameSettings.kt index 68fbf6d03..d2024513f 100644 --- a/app/src/main/feature/retro/RetroGameSettings.kt +++ b/app/src/main/feature/retro/RetroGameSettings.kt @@ -92,8 +92,8 @@ private val FieldCorner = 8.dp private val ItemGap = 10.dp private val TightGap = 4.dp -private val SHADER_KEYS = listOf("default", "crt", "lcd", "sharp") -private val SHADER_LABELS = listOf("Default", "CRT", "LCD", "Sharp") +private val SHADER_KEYS = listOf("default", "sgsr", "crt", "lcd", "sharp") +private val SHADER_LABELS = listOf("Default", "SGSR", "CRT", "LCD", "Sharp") class RetroSettingsState( val shortcut: Shortcut, diff --git a/build.gradle b/build.gradle index 70ec1c14c..7cebf38d6 100644 --- a/build.gradle +++ b/build.gradle @@ -1,5 +1,6 @@ plugins { alias(libs.plugins.androidApplication) apply false + alias(libs.plugins.androidLibrary) apply false alias(libs.plugins.kotlinAndroid) apply false alias(libs.plugins.kotlinCompose) apply false alias(libs.plugins.ksp) apply false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 614327ea6..3e16b76e1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -75,6 +75,7 @@ workRuntimeKtx = { module = "androidx.work:work-runtime-ktx", version.ref = "wor [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } +androidLibrary = { id = "com.android.library", version.ref = "agp" } kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlinCompose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } diff --git a/libretrodroid/.gitignore b/libretrodroid/.gitignore new file mode 100644 index 000000000..796b96d1c --- /dev/null +++ b/libretrodroid/.gitignore @@ -0,0 +1 @@ +/build diff --git a/libretrodroid/build.gradle b/libretrodroid/build.gradle new file mode 100644 index 000000000..ab9410149 --- /dev/null +++ b/libretrodroid/build.gradle @@ -0,0 +1,56 @@ +plugins { + alias(libs.plugins.androidLibrary) + alias(libs.plugins.kotlinAndroid) +} + +android { + namespace = "com.swordfish.libretrodroid" + compileSdk 35 + ndkVersion '27.3.13750724' + + defaultConfig { + minSdk 26 + + consumerProguardFiles 'consumer-rules.pro' + + externalNativeBuild { + cmake { + arguments '-DANDROID_STL=c++_static', + "-DCMAKE_SHARED_LINKER_FLAGS=-Wl,-z,max-page-size=16384" + } + } + + ndk { + abiFilters 'arm64-v8a' + } + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + + externalNativeBuild { + cmake { + version '3.22.1' + path 'src/main/cpp/CMakeLists.txt' + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + implementation libs.coreKtx + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.5.1' +} diff --git a/libretrodroid/consumer-rules.pro b/libretrodroid/consumer-rules.pro new file mode 100644 index 000000000..e69de29bb diff --git a/libretrodroid/proguard-rules.pro b/libretrodroid/proguard-rules.pro new file mode 100644 index 000000000..f1b424510 --- /dev/null +++ b/libretrodroid/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/libretrodroid/src/main/AndroidManifest.xml b/libretrodroid/src/main/AndroidManifest.xml new file mode 100644 index 000000000..f47302e5f --- /dev/null +++ b/libretrodroid/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + diff --git a/libretrodroid/src/main/cpp/CMakeLists.txt b/libretrodroid/src/main/cpp/CMakeLists.txt new file mode 100644 index 000000000..5a845df04 --- /dev/null +++ b/libretrodroid/src/main/cpp/CMakeLists.txt @@ -0,0 +1,102 @@ +cmake_minimum_required(VERSION 3.13.2) + +# now build app's shared lib +set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++17 -Wall") + +add_definitions("-DVFS_FRONTEND -DHAVE_STRL") + +# Let's include oboe +set (OBOE_DIR oboe) +add_subdirectory (${OBOE_DIR} oboe) +include_directories (${OBOE_DIR}/include) +include_directories (${OBOE_DIR}/src) + +include_directories("libretro/libretro-common/include") + +set (LIBRETRO_COMMON + libretro/libretro-common/vfs/vfs_implementation.c + libretro/libretro-common/string/stdstring.c + libretro/libretro-common/encodings/encoding_utf.c + libretro/libretro-common/file/file_path.c + libretro/libretro-common/time/rtime.c +) + +add_library(libretrodroid SHARED + libretrodroidjni.h + libretrodroidjni.cpp + libretrodroid.h + libretrodroid.cpp + log.h + core.h + core.cpp + video.h + video.cpp + immersivemode.h + immersivemode.cpp + videolayout.h + videolayout.cpp + renderers/renderer.h + renderers/renderer.cpp + renderers/es3/es3utils.h + renderers/es3/es3utils.cpp + renderers/es3/framebufferrenderer.h + renderers/es3/framebufferrenderer.cpp + renderers/es2/imagerendereres2.h + renderers/es2/imagerendereres2.cpp + renderers/es3/imagerendereres3.h + renderers/es3/imagerendereres3.cpp + audio.h + audio.cpp + resamplers/resampler.h + resamplers/linearresampler.h + resamplers/linearresampler.cpp + resamplers/sincresampler.h + resamplers/sincresampler.cpp + fpssync.h + fpssync.cpp + environment.h + environment.cpp + input.h + input.cpp + shadermanager.h + shadermanager.cpp + rumble.h + rumble.cpp + rumblestate.h + rumblestate.cpp + utils/javautils.h + utils/javautils.cpp + utils/utils.cpp + utils/utils.h + utils/jnistring.h + utils/jnistring.cpp + utils/libretrodroidexception.h + utils/libretrodroidexception.cpp + utils/rect.h + utils/rect.cpp + errorcodes.h + errorcodes.cpp + vfs/vfs.h + vfs/vfs.cpp + vfs/vfsfile.h + vfs/vfsfile.cpp + vfs/fdwrapper.h + vfs/fdwrapper.cpp + microphone/microphone.h + microphone/microphone.cpp + microphone/microphoneinterface.h + microphone/microphoneinterface.cpp + ${LIBRETRO_COMMON} +) + +# Support 16k pages for Android 15 +target_link_options(libretrodroid PRIVATE "-Wl,-z,max-page-size=16384") + +# add lib dependencies +target_link_libraries(libretrodroid + android + log + EGL + oboe + GLESv3 +) diff --git a/libretrodroid/src/main/cpp/SGSR_LICENSE b/libretrodroid/src/main/cpp/SGSR_LICENSE new file mode 100644 index 000000000..d9d513c75 --- /dev/null +++ b/libretrodroid/src/main/cpp/SGSR_LICENSE @@ -0,0 +1,29 @@ +Copyright (c) 2023, Qualcomm Innovation Center, Inc. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE +ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE +LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR +CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN +CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) +ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE +POSSIBILITY OF SUCH DAMAGE. + +SPDX-License-Identifier: BSD-3-Clause \ No newline at end of file diff --git a/libretrodroid/src/main/cpp/audio.cpp b/libretrodroid/src/main/cpp/audio.cpp new file mode 100644 index 000000000..c6e721a8e --- /dev/null +++ b/libretrodroid/src/main/cpp/audio.cpp @@ -0,0 +1,171 @@ +/* + * Copyright (C) 2019 Filippo Scognamiglio + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "log.h" + +#include "audio.h" +#include +#include + +namespace libretrodroid { + +Audio::Audio(int32_t sampleRate, double refreshRate, bool preferLowLatencyAudio) { + LOGI("Audio initialization has been called with input sample rate %d", sampleRate); + + contentRefreshRate = refreshRate; + inputSampleRate = sampleRate; + audioLatencySettings = findBestLatencySettings(preferLowLatencyAudio); + initializeStream(); +} + +bool Audio::initializeStream() { + LOGI("Using low latency stream: %d", audioLatencySettings->useLowLatencyStream); + + int32_t audioBufferSize = computeAudioBufferSize(); + + oboe::AudioStreamBuilder builder; + builder.setChannelCount(2); + builder.setDirection(oboe::Direction::Output); + builder.setFormat(oboe::AudioFormat::I16); + builder.setDataCallback(this); + builder.setErrorCallback(this); + + if (audioLatencySettings->useLowLatencyStream) { + builder.setPerformanceMode(oboe::PerformanceMode::LowLatency); + } else { + builder.setFramesPerCallback(audioBufferSize / 10); + } + + oboe::Result result = builder.openManagedStream(stream); + if (result == oboe::Result::OK) { + baseConversionFactor = (double) inputSampleRate / stream->getSampleRate(); + fifoBuffer = std::make_unique(2, audioBufferSize); + temporaryAudioBuffer = std::unique_ptr(new int16_t[audioBufferSize]); + latencyTuner = std::make_unique(*stream); + return true; + } else { + LOGE("Failed to create stream. Error: %s", oboe::convertToText(result)); + stream = nullptr; + latencyTuner = nullptr; + return false; + } +} + +std::unique_ptr Audio::findBestLatencySettings(bool preferLowLatencyAudio) { + if (oboe::AudioStreamBuilder::isAAudioRecommended() && preferLowLatencyAudio) { + return std::make_unique(LOW_LATENCY_SETTINGS); + } else { + return std::make_unique(DEFAULT_LATENCY_SETTINGS); + } +} + +int32_t Audio::computeAudioBufferSize() { + double maxLatency = computeMaximumLatency(); + LOGI("Average audio latency set to: %f ms", maxLatency * 0.5); + double sampleRateDivisor = 500.0 / maxLatency; + return roundToEven(inputSampleRate / sampleRateDivisor); +} + +double Audio::computeMaximumLatency() const { + double maxLatency = (audioLatencySettings->bufferSizeInVideoFrames / contentRefreshRate) * 1000; + return std::max(maxLatency, 32.0); +} + +void Audio::start() { + startRequested = true; + if (stream != nullptr) + stream->requestStart(); +} + +void Audio::stop() { + startRequested = false; + if (stream != nullptr) + stream->requestStop(); +} + +void Audio::write(const int16_t *data, size_t frames) { + fifoBuffer->write(data, frames * 2); +} + +void Audio::setPlaybackSpeed(const double newPlaybackSpeed) { + playbackSpeed = newPlaybackSpeed; +} + +oboe::DataCallbackResult Audio::onAudioReady(oboe::AudioStream *oboeStream, void *audioData, int32_t numFrames) { + double dynamicBufferFactor = computeDynamicBufferConversionFactor(0.001 * numFrames); + double finalConversionFactor = baseConversionFactor * dynamicBufferFactor * playbackSpeed; + + // When using low-latency stream, numFrames is very low (~100) and the dynamic buffer scaling doesn't work with rounding. + // By keeping track of the "fractional" frames we can keep the error smaller. + framesToSubmit += numFrames * finalConversionFactor; + int32_t currentFramesToSubmit = std::round(framesToSubmit); + framesToSubmit -= currentFramesToSubmit; + + fifoBuffer->readNow(temporaryAudioBuffer.get(), currentFramesToSubmit * 2); + + auto outputArray = reinterpret_cast(audioData); + resampler.resample(temporaryAudioBuffer.get(), currentFramesToSubmit, outputArray, numFrames); + + latencyTuner->tune(); + + return oboe::DataCallbackResult::Continue; +} + +// To prevent audio buffer overruns or underruns we set up a PI controller. The idea is to run the +// audio slower when the buffer is empty and faster when it's full. +double Audio::computeDynamicBufferConversionFactor(double dt) { + double framesCapacityInBuffer = fifoBuffer->getBufferCapacityInFrames(); + double framesAvailableInBuffer = fifoBuffer->getFullFramesAvailable(); + + // Error is represented by normalized distance to half buffer utilization. Range [-1.0, 1.0] + double errorMeasure = (framesCapacityInBuffer - 2.0f * framesAvailableInBuffer) / framesCapacityInBuffer; + + errorIntegral += errorMeasure * dt; + + // Wikipedia states that human ear resolution is around 3.6 Hz within the octave of 1000–2000 Hz. + // This changes continuously, so we should try to keep it a very low value. + double proportionalAdjustment = std::clamp(kp * errorMeasure, -maxp, maxp); + + // Ki is a lot lower, so it's safe if it exceeds the ear threshold. Hopefully convergence will + // be slow enough to be not perceptible. We need to battle test this value. + double integralAdjustment = std::clamp(ki * errorIntegral, -maxi, maxi); + + double finalAdjustment = proportionalAdjustment + integralAdjustment; + + LOGD("Audio speed adjustments (p: %f) (i: %f)", proportionalAdjustment, integralAdjustment); + + return 1.0 - (finalAdjustment); +} + +int32_t Audio::roundToEven(int32_t x) { + return (x / 2) * 2; +} + +void Audio::onErrorAfterClose(oboe::AudioStream* oldStream, oboe::Result result) { + AudioStreamErrorCallback::onErrorAfterClose(oldStream, result); + LOGI("Stream error in oboe::onErrorAfterClose %s", oboe::convertToText(result)); + + if (result != oboe::Result::ErrorDisconnected) + return; + + initializeStream(); + if (startRequested) { + start(); + } +} + +} //namespace libretrodroid diff --git a/libretrodroid/src/main/cpp/audio.h b/libretrodroid/src/main/cpp/audio.h new file mode 100644 index 000000000..b0cb5ed28 --- /dev/null +++ b/libretrodroid/src/main/cpp/audio.h @@ -0,0 +1,96 @@ +/* + * Copyright (C) 2019 Filippo Scognamiglio + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef LIBRETRODROID_AUDIO_H +#define LIBRETRODROID_AUDIO_H + +#include +#include +#include +#include + +#include "resamplers/linearresampler.h" + +namespace libretrodroid { + +class Audio: public oboe::AudioStreamDataCallback, oboe::AudioStreamErrorCallback { +private: + struct AudioLatencySettings { + unsigned bufferSizeInVideoFrames; + bool useLowLatencyStream; + }; + + const AudioLatencySettings DEFAULT_LATENCY_SETTINGS { 8, false }; + const AudioLatencySettings LOW_LATENCY_SETTINGS { 4, true }; + +public: + Audio(int32_t sampleRate, double refreshRate, bool preferLowLatencyAudio); + ~Audio() override = default; + + void start(); + void stop(); + + oboe::DataCallbackResult onAudioReady( + oboe::AudioStream *oboeStream, + void *audioData, + int32_t numFrames + ) override; + + void onErrorAfterClose(oboe::AudioStream *oldStream, oboe::Result result) override; + +public: + void write(const int16_t *data, size_t frames); + void setPlaybackSpeed(const double newPlaybackSpeed); + +private: + static int32_t roundToEven(int32_t x); + double computeDynamicBufferConversionFactor(double dt); + int32_t computeAudioBufferSize(); + bool initializeStream(); + std::unique_ptr findBestLatencySettings(bool preferLowLatencyAudio); + double computeMaximumLatency() const; + +private: + const double kp = 0.006; + const double ki = 0.00002; + const double maxp = 0.003; + const double maxi = 0.02; + + LinearResampler resampler; + std::unique_ptr fifoBuffer = nullptr; + std::unique_ptr temporaryAudioBuffer = nullptr; + + oboe::ManagedStream stream = nullptr; + std::unique_ptr latencyTuner = nullptr; + + bool startRequested = false; + int32_t inputSampleRate; + double contentRefreshRate = 60.0; + + double baseConversionFactor = 1.0; + + double framesToSubmit = 0.0; + double errorIntegral = 0.0; + + double playbackSpeed = 1.0; + + std::unique_ptr audioLatencySettings; +}; + +} // namespace libretrodroid + +#endif //LIBRETRODROID_AUDIO_H diff --git a/libretrodroid/src/main/cpp/core.cpp b/libretrodroid/src/main/cpp/core.cpp new file mode 100644 index 000000000..f72d78d2a --- /dev/null +++ b/libretrodroid/src/main/cpp/core.cpp @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2019 Filippo Scognamiglio + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include +#include "core.h" + +#include "log.h" + +namespace libretrodroid { + +Core::Core(const std::string& soCorePath) { + open(soCorePath); +} + +void* get_symbol(void* handle, const char* symbol) { + void* result = dlsym(handle, symbol); + if (!result) { + LOGE("Cannot get symbol %s... Quitting", symbol); + throw std::runtime_error("Missing libretro core symbol"); + } + return result; +} + +void Core::open(const std::string& soCorePath) { + libHandle = dlopen(soCorePath.c_str(), RTLD_LOCAL | RTLD_LAZY); + if (!libHandle) { + LOGE("Cannot dlopen library, closing"); + throw std::runtime_error("Cannot dlopen library"); + } + retro_cheat_reset = (void (*)()) get_symbol(libHandle, "retro_cheat_reset"); + retro_cheat_set = (void (*)(unsigned, bool, const char*)) get_symbol(libHandle, "retro_cheat_set"); + retro_init = (void (*)()) get_symbol(libHandle, "retro_init"); + retro_deinit = (void (*)()) get_symbol(libHandle, "retro_deinit"); + retro_api_version = (unsigned (*)()) get_symbol(libHandle, "retro_api_version"); + retro_get_system_info = (void (*)(struct retro_system_info*)) get_symbol(libHandle, "retro_get_system_info"); + retro_get_system_av_info = (void (*)(struct retro_system_av_info*)) get_symbol(libHandle, "retro_get_system_av_info"); + retro_set_controller_port_device = (void (*)(unsigned, unsigned)) get_symbol(libHandle, "retro_set_controller_port_device"); + retro_reset = (void (*)()) get_symbol(libHandle, "retro_reset"); + retro_run = (void (*)()) get_symbol(libHandle, "retro_run"); + retro_serialize_size = (size_t (*)()) get_symbol(libHandle, "retro_serialize_size"); + retro_serialize = (bool (*)(void*, size_t)) get_symbol(libHandle, "retro_serialize"); + retro_unserialize = (bool (*)(const void*, size_t)) get_symbol(libHandle, "retro_unserialize"); + retro_get_memory_size = (size_t (*)(unsigned)) get_symbol(libHandle, "retro_get_memory_size"); + retro_get_memory_data = (void* (*)(unsigned)) get_symbol(libHandle, "retro_get_memory_data"); + retro_load_game = (bool (*)(const struct retro_game_info*)) get_symbol(libHandle, "retro_load_game"); + retro_unload_game = (void (*)()) get_symbol(libHandle, "retro_unload_game"); + retro_set_video_refresh = (void (*)(retro_video_refresh_t)) get_symbol(libHandle, "retro_set_video_refresh"); + retro_set_environment = (void (*)(retro_environment_t)) get_symbol(libHandle, "retro_set_environment"); + retro_set_audio_sample = (void (*)(retro_audio_sample_t)) get_symbol(libHandle, "retro_set_audio_sample"); + retro_set_audio_sample_batch = (void (*)(retro_audio_sample_batch_t)) get_symbol(libHandle, "retro_set_audio_sample_batch"); + retro_set_input_poll = (void (*)(retro_input_poll_t)) get_symbol(libHandle, "retro_set_input_poll"); + retro_set_input_state = (void (*)(retro_input_state_t)) get_symbol(libHandle, "retro_set_input_state"); +} + +void Core::close() { + if (libHandle) { + dlclose(libHandle); + libHandle = nullptr; + } +} + +Core::~Core() { + close(); +} + +} //namespace libretrodroid diff --git a/libretrodroid/src/main/cpp/core.h b/libretrodroid/src/main/cpp/core.h new file mode 100644 index 000000000..2957566e5 --- /dev/null +++ b/libretrodroid/src/main/cpp/core.h @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2019 Filippo Scognamiglio + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef LIBRETRODROID_CORE_H +#define LIBRETRODROID_CORE_H + +#include + +#include "../../libretro-common/include/libretro.h" + +namespace libretrodroid { + +class Core { +public: + void (*retro_init)(void); + void (*retro_deinit)(void); + unsigned (*retro_api_version)(void); + void (*retro_cheat_reset)(void); + void (*retro_cheat_set)(unsigned index, bool enabled, const char *code); + void (*retro_get_system_info)(struct retro_system_info *info); + void (*retro_get_system_av_info)(struct retro_system_av_info *info); + void (*retro_set_controller_port_device)(unsigned port, unsigned device); + void (*retro_reset)(void); + void (*retro_run)(void); + size_t (*retro_serialize_size)(void); + bool (*retro_serialize)(void *data, size_t size); + bool (*retro_unserialize)(const void *data, size_t size); + size_t (*retro_get_memory_size)(unsigned id); + void* (*retro_get_memory_data)(unsigned id); + bool (*retro_load_game)(const struct retro_game_info *game); + void (*retro_unload_game)(void); + void (*retro_set_video_refresh)(retro_video_refresh_t); + void (*retro_set_environment)(retro_environment_t); + void (*retro_set_audio_sample)(retro_audio_sample_t); + void (*retro_set_audio_sample_batch)(retro_audio_sample_batch_t); + void (*retro_set_input_poll)(retro_input_poll_t); + void (*retro_set_input_state)(retro_input_state_t); + + Core(const std::string& soCorePath); + ~Core(); + +private: + void open(const std::string& soCorePath); + void close(); + + void* libHandle = nullptr; +}; + +} + + + +#endif //LIBRETRODROID_CORE_H diff --git a/libretrodroid/src/main/cpp/environment.cpp b/libretrodroid/src/main/cpp/environment.cpp new file mode 100644 index 000000000..941de81a7 --- /dev/null +++ b/libretrodroid/src/main/cpp/environment.cpp @@ -0,0 +1,482 @@ +/* + * Copyright (C) 2020 Filippo Scognamiglio + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#define MODULE_NAME_CORE "Libretro Core" + +#include +#include +#include +#include +#include +#include +#include + +#include "../../libretro-common/include/libretro.h" +#include "log.h" +#include "environment.h" +#include "vfs/vfs.h" +#include "microphone/microphoneinterface.h" + +void Environment::initialize( + const std::string &requiredSystemDirectory, + const std::string &requiredSavesDirectory, + retro_hw_get_current_framebuffer_t required_callback_get_current_framebuffer +) { + callback_get_current_framebuffer = required_callback_get_current_framebuffer; + systemDirectory = requiredSystemDirectory; + savesDirectory = requiredSavesDirectory; +} + +void Environment::deinitialize() { + callback_get_current_framebuffer = nullptr; + hw_context_reset = nullptr; + hw_context_destroy = nullptr; + + retro_disk_control_callback = nullptr; + + savesDirectory = std::string(); + systemDirectory = std::string(); + language = RETRO_LANGUAGE_ENGLISH; + + pixelFormat = RETRO_PIXEL_FORMAT_RGB565; + useHWAcceleration = false; + useDepth = false; + useStencil = false; + bottomLeftOrigin = false; + screenRotation = 0; + + gameGeometryUpdated = false; + gameGeometryWidth = 0; + gameGeometryHeight = 0; + gameGeometryAspectRatio = -1.0f; + + rumbleStates.fill(libretrodroid::RumbleState {}); +} + +void Environment::updateVariable(const std::string& key, const std::string& value) { + auto current = variables[key]; + current.key = key; + + if (value != current.value) { + current.value = value; + variables[key] = current; + dirtyVariables = true; + } +} + +bool Environment::environment_handle_set_variables(const struct retro_variable* received) { + unsigned count = 0; + while (received[count].key != nullptr) { + LOGD("Received variable %s: %s", received[count].key, received[count].value); + + std::string key(received[count].key); + std::string description(received[count].value); + std::string value(received[count].value); + + auto firstValueStart = value.find(';') + 2; + auto firstValueEnd = value.find('|', firstValueStart); + value = value.substr(firstValueStart, firstValueEnd - firstValueStart); + + auto currentVariable = variables[key]; + currentVariable.key = key; + currentVariable.description = description; + + if (currentVariable.value.empty()) { + currentVariable.value = value; + } + + variables[key] = currentVariable; + LOGD("Assigning variable %s: %s", currentVariable.key.c_str(), currentVariable.value.c_str()); + + count++; + } + + return true; +} + +bool Environment::environment_handle_get_variable(struct retro_variable* requested) { + LOGD("Variable requested %s", requested->key); + auto foundVariable = variables.find(std::string(requested->key)); + + if (foundVariable == variables.end()) { + return false; + } + + requested->value = foundVariable->second.value.c_str(); + return true; +} + +bool Environment::environment_handle_set_controller_info(const struct retro_controller_info* received) { + controllers.clear(); + + unsigned player = 0; + while (received[player].types != nullptr) { + + auto currentPlayer = received[player]; + + controllers.emplace_back(); + + unsigned controller = 0; + while (controller < currentPlayer.num_types && currentPlayer.types[controller].desc != nullptr) { + auto currentController = currentPlayer.types[controller]; + LOGD("Received controller for player %d: %d %s", player, currentController.id, currentController.desc); + + controllers[player].push_back(Controller { currentController.id, currentController.desc }); + controller++; + } + + player++; + } + + return true; +} + +bool Environment::environment_handle_set_hw_render(struct retro_hw_render_callback* hw_render_callback) { + useHWAcceleration = true; + useDepth = hw_render_callback->depth; + useStencil = hw_render_callback->stencil; + bottomLeftOrigin = hw_render_callback->bottom_left_origin; + + hw_context_destroy = hw_render_callback->context_destroy; + hw_context_reset = hw_render_callback->context_reset; + hw_render_callback->get_current_framebuffer = callback_get_current_framebuffer; + hw_render_callback->get_proc_address = &eglGetProcAddress; + + return true; +} + +bool Environment::environment_handle_get_vfs_interface(struct retro_vfs_interface_info* vfsInterfaceInfo) { + if (!useVirtualFileSystem) { + return false; + } + + vfsInterfaceInfo->required_interface_version = libretrodroid::VFS::SUPPORTED_VERSION; + vfsInterfaceInfo->iface = libretrodroid::VFS::getInterface(); + return true; +} + +bool Environment::environment_handle_get_microphone_interface(struct retro_microphone_interface* microphone_interface) { + if (!enableMicrophone) { + return false; + } + + *microphone_interface = *libretrodroid::MicrophoneInterface::getInterface(); + return true; +} + +void Environment::callback_retro_log(enum retro_log_level level, const char *fmt, ...) { + va_list argptr; + va_start(argptr, fmt); + + switch (level) { +#if VERBOSE_LOGGING + case RETRO_LOG_DEBUG: + __android_log_vprint(ANDROID_LOG_DEBUG, MODULE_NAME_CORE, fmt, argptr); + break; +#endif + case RETRO_LOG_INFO: + __android_log_vprint(ANDROID_LOG_INFO, MODULE_NAME_CORE, fmt, argptr); + break; + case RETRO_LOG_WARN: + __android_log_vprint(ANDROID_LOG_WARN, MODULE_NAME_CORE, fmt, argptr); + break; + case RETRO_LOG_ERROR: + __android_log_vprint(ANDROID_LOG_ERROR, MODULE_NAME_CORE, fmt, argptr); + break; + default: + // Log nothing in here. + break; + } +} + +bool Environment::callback_set_rumble_state(unsigned port, enum retro_rumble_effect effect, uint16_t strength) { + return Environment::getInstance().handle_callback_set_rumble_state(port, effect, strength); +} + +bool Environment::handle_callback_set_rumble_state(unsigned port, enum retro_rumble_effect effect, uint16_t strength) { + LOGV("Setting rumble strength for port %i to %i", port, strength); + if (port < 0 || port > 3) return false; + + if (effect == RETRO_RUMBLE_STRONG) { + rumbleStates[port].strengthStrong = strength; + } else if (effect == RETRO_RUMBLE_WEAK) { + rumbleStates[port].strengthWeak = strength; + } + + return true; +} + +bool Environment::callback_environment(unsigned cmd, void *data) { + return Environment::getInstance().handle_callback_environment(cmd, data); +} + +bool Environment::handle_callback_environment(unsigned cmd, void *data) { + switch (cmd) { + case RETRO_ENVIRONMENT_GET_CAN_DUPE: + *((bool*) data) = true; + return true; + + case RETRO_ENVIRONMENT_SET_PIXEL_FORMAT: { + LOGD("Called SET_PIXEL_FORMAT"); + pixelFormat = *static_cast(data); + return true; + } + + case RETRO_ENVIRONMENT_SET_INPUT_DESCRIPTORS: + LOGD("Called SET_INPUT_DESCRIPTORS"); + return false; + + case RETRO_ENVIRONMENT_GET_VARIABLE: + LOGD("Called RETRO_ENVIRONMENT_GET_VARIABLE"); + return environment_handle_get_variable(static_cast(data)); + + case RETRO_ENVIRONMENT_SET_VARIABLES: + LOGD("Called RETRO_ENVIRONMENT_SET_VARIABLES"); + return environment_handle_set_variables(static_cast(data)); + + case RETRO_ENVIRONMENT_GET_VARIABLE_UPDATE: { + LOGD("Called RETRO_ENVIRONMENT_GET_VARIABLE_UPDATE. Is dirty?: %d", dirtyVariables); + *((bool*) data) = dirtyVariables; + dirtyVariables = false; + return true; + } + + case RETRO_ENVIRONMENT_GET_PREFERRED_HW_RENDER: { + LOGD("Called RETRO_ENVIRONMENT_GET_PREFERRED_HW_RENDER"); + *((unsigned*) data) = retro_hw_context_type::RETRO_HW_CONTEXT_OPENGLES3; + return true; + } + + case RETRO_ENVIRONMENT_SET_HW_RENDER: + LOGD("Called RETRO_ENVIRONMENT_SET_HW_RENDER"); + return environment_handle_set_hw_render(static_cast(data)); + + case RETRO_ENVIRONMENT_GET_RUMBLE_INTERFACE: + LOGD("Called RETRO_ENVIRONMENT_GET_RUMBLE_INTERFACE"); + ((struct retro_rumble_interface*) data)->set_rumble_state = &callback_set_rumble_state; + return true; + + case RETRO_ENVIRONMENT_GET_LOG_INTERFACE: + LOGD("Called RETRO_ENVIRONMENT_GET_LOG_INTERFACE"); + ((struct retro_log_callback*) data)->log = &callback_retro_log; + return true; + + case RETRO_ENVIRONMENT_GET_SAVE_DIRECTORY: + LOGD("Called RETRO_ENVIRONMENT_GET_SAVE_DIRECTORY"); + *(const char**) data = savesDirectory.c_str(); + return !savesDirectory.empty(); + + case RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY: + LOGD("Called RETRO_ENVIRONMENT_GET_SYSTEM_DIRECTORY"); + *(const char**) data = systemDirectory.c_str(); + return !systemDirectory.empty(); + + case RETRO_ENVIRONMENT_SET_ROTATION: { + LOGD("Called RETRO_ENVIRONMENT_SET_ROTATION"); + unsigned screenRotationIndex = (*static_cast(data)); + screenRotation = screenRotationIndex * (float) (-M_PI / 2.0); + screenRotationUpdated = true; + return true; + } + + case RETRO_ENVIRONMENT_SET_DISK_CONTROL_INTERFACE: { + LOGD("Called RETRO_ENVIRONMENT_SET_ROTATION"); + retro_disk_control_callback = static_cast(data); + return true; + } + + case RETRO_ENVIRONMENT_GET_PERF_INTERFACE: + LOGD("Called RETRO_ENVIRONMENT_GET_PERF_INTERFACE"); + return false; + + // TODO... RETRO_ENVIRONMENT_SET_SYSTEM_AV_INFO can also change frame-rate + case RETRO_ENVIRONMENT_SET_SYSTEM_AV_INFO: + case RETRO_ENVIRONMENT_SET_GEOMETRY: { + struct retro_game_geometry *geometry = static_cast(data); + gameGeometryHeight = geometry->base_height; + gameGeometryWidth = geometry->base_width; + gameGeometryAspectRatio = geometry->aspect_ratio; + gameGeometryUpdated = true; + return true; + } + + case RETRO_ENVIRONMENT_SET_CONTROLLER_INFO: + LOGD("Called RETRO_ENVIRONMENT_SET_CONTROLLER_INFO"); + return environment_handle_set_controller_info(static_cast(data)); + + case RETRO_ENVIRONMENT_GET_AUDIO_VIDEO_ENABLE: + LOGD("Called RETRO_ENVIRONMENT_GET_AUDIO_VIDEO_ENABLE"); + return false; + + case RETRO_ENVIRONMENT_GET_LANGUAGE: + LOGD("Called RETRO_ENVIRONMENT_GET_LANGUAGE"); + *((unsigned*) data) = language; + return true; + + case RETRO_ENVIRONMENT_GET_VFS_INTERFACE: + LOGD("Called RETRO_ENVIRONMENT_GET_VFS_INTERFACE"); + return environment_handle_get_vfs_interface(static_cast(data)); + + case RETRO_ENVIRONMENT_GET_MICROPHONE_INTERFACE: + LOGD("Called RETRO_ENVIRONMENT_GET_MICROPHONE_INTERFACE"); + return environment_handle_get_microphone_interface(static_cast(data)); + + default: + LOGD("callback environment has been called: %u", cmd); + return false; + } +} + +void Environment::setLanguage(const std::string& androidLanguage) { + std::unordered_map languages { + { "en", RETRO_LANGUAGE_ENGLISH }, + { "jp", RETRO_LANGUAGE_JAPANESE }, + { "fr", RETRO_LANGUAGE_FRENCH }, + { "es", RETRO_LANGUAGE_SPANISH }, + { "de", RETRO_LANGUAGE_GERMAN }, + { "it", RETRO_LANGUAGE_ITALIAN }, + { "nl", RETRO_LANGUAGE_DUTCH }, + { "pt", RETRO_LANGUAGE_PORTUGUESE_PORTUGAL }, + { "ru", RETRO_LANGUAGE_RUSSIAN }, + { "ko", RETRO_LANGUAGE_KOREAN }, + { "zh", RETRO_LANGUAGE_CHINESE_TRADITIONAL }, + { "eo", RETRO_LANGUAGE_ESPERANTO }, + { "pl", RETRO_LANGUAGE_POLISH }, + { "vi", RETRO_LANGUAGE_VIETNAMESE }, + { "ar", RETRO_LANGUAGE_ARABIC }, + { "el", RETRO_LANGUAGE_GREEK }, + { "tr", RETRO_LANGUAGE_TURKISH } + }; + + if (languages.find(androidLanguage) != languages.end()) { + language = languages[androidLanguage]; + } +} + +retro_hw_context_reset_t Environment::getHwContextReset() const { + return hw_context_reset; +} + +retro_hw_context_reset_t Environment::getHwContextDestroy() const { + return hw_context_destroy; +} + +struct retro_disk_control_callback* Environment::getRetroDiskControlCallback() const { + return retro_disk_control_callback; +} + +int Environment::getPixelFormat() const { + return pixelFormat; +} + +bool Environment::isUseHwAcceleration() const { + return useHWAcceleration; +} + +bool Environment::isUseDepth() const { + return useDepth; +} + +bool Environment::isUseStencil() const { + return useStencil; +} + +bool Environment::isBottomLeftOrigin() const { + return bottomLeftOrigin; +} + +float Environment::getScreenRotation() const { + return screenRotation; +} + +bool Environment::isGameGeometryUpdated() const { + return gameGeometryUpdated; +} + +void Environment::clearGameGeometryUpdated() { + gameGeometryUpdated = false; +} + +unsigned int Environment::getGameGeometryWidth() const { + return gameGeometryWidth; +} + +unsigned int Environment::getGameGeometryHeight() const { + return gameGeometryHeight; +} + +float Environment::getGameGeometryAspectRatio() const { + return gameGeometryAspectRatio; +} + +const std::vector Environment::getVariables() const { + std::vector result; + + std::for_each( + variables.begin(), + variables.end(), + [&](std::pair item) { + result.push_back(item.second); + } + ); + + std::sort( + result.begin(), + result.end(), + [](struct Variable v1, struct Variable v2) { + return v1.key < v2.key; + } + ); + + return result; +} + +const std::vector> &Environment::getControllers() const { + return controllers; +} + +float Environment::retrieveGameSpecificAspectRatio() { + if (getGameGeometryAspectRatio() > 0) { + return getGameGeometryAspectRatio(); + } + + if (getGameGeometryWidth() > 0 && getGameGeometryHeight() > 0) { + return (float) getGameGeometryWidth() / (float) getGameGeometryHeight(); + } + + return -1.0f; +} + +bool Environment::isScreenRotationUpdated() const { + return screenRotationUpdated; +} + +void Environment::clearScreenRotationUpdated() { + screenRotationUpdated = false; +} + +std::array& Environment::getLastRumbleStates() { + return rumbleStates; +} + +void Environment::setEnableVirtualFileSystem(bool value) { + this->useVirtualFileSystem = value; +} + +void Environment::setEnableMicrophone(bool value) { + this->enableMicrophone = value; +} diff --git a/libretrodroid/src/main/cpp/environment.h b/libretrodroid/src/main/cpp/environment.h new file mode 100644 index 000000000..9f4a06c67 --- /dev/null +++ b/libretrodroid/src/main/cpp/environment.h @@ -0,0 +1,167 @@ +/* + * Copyright (C) 2020 Filippo Scognamiglio + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef LIBRETRODROID_ENVIRONMENT_H +#define LIBRETRODROID_ENVIRONMENT_H + +#define MODULE_NAME_CORE "Libretro Core" + +#include +#include +#include +#include +#include +#include +#include + +#include "../../libretro-common/include/libretro.h" +#include "log.h" +#include "rumblestate.h" + +class Environment { +public: + static Environment& getInstance() + { + static Environment instance; + return instance; + } + Environment(Environment const&) = delete; + void operator=(Environment const&) = delete; + + static void callback_retro_log(enum retro_log_level level, const char *fmt, ...); + + static bool callback_set_rumble_state( + unsigned port, + enum retro_rumble_effect effect, + uint16_t strength + ); + + static bool callback_environment(unsigned cmd, void *data); + + void setEnableVirtualFileSystem(bool value); + void setEnableMicrophone(bool value); + +private: + Environment() {} + +public: + void initialize( + const std::string &requiredSystemDirectory, + const std::string &requiredSavesDirectory, + retro_hw_get_current_framebuffer_t required_callback_get_current_framebuffer + ); + + void deinitialize(); + + void updateVariable(const std::string &key, const std::string &value); + + void setLanguage(const std::string &androidLanguage); + + float retrieveGameSpecificAspectRatio(); + + bool handle_callback_set_rumble_state( + unsigned port, + enum retro_rumble_effect effect, + uint16_t strength + ); + + bool handle_callback_environment(unsigned cmd, void *data); + + retro_hw_context_reset_t getHwContextReset() const; + retro_hw_context_reset_t getHwContextDestroy() const; + + struct retro_disk_control_callback* getRetroDiskControlCallback() const; + + int getPixelFormat() const; + bool isUseHwAcceleration() const; + bool isUseDepth() const; + bool isUseStencil() const; + bool isBottomLeftOrigin() const; + + float getScreenRotation() const; + bool isScreenRotationUpdated() const; + void clearScreenRotationUpdated(); + + unsigned int getGameGeometryWidth() const; + unsigned int getGameGeometryHeight() const; + float getGameGeometryAspectRatio() const; + bool isGameGeometryUpdated() const; + void clearGameGeometryUpdated(); + + std::array & getLastRumbleStates(); + + const std::vector getVariables() const; + + const std::vector> &getControllers() const; + +private: + bool environment_handle_set_variables(const struct retro_variable* received); + bool environment_handle_get_variable(struct retro_variable* requested); + bool environment_handle_set_controller_info(const struct retro_controller_info* received); + bool environment_handle_set_hw_render(struct retro_hw_render_callback* hw_render_callback); + bool environment_handle_get_vfs_interface(struct retro_vfs_interface_info* vfs_interface_info); + bool environment_handle_get_microphone_interface(struct retro_microphone_interface* microphone_interface); + +private: + retro_hw_context_reset_t hw_context_reset = nullptr; + retro_hw_context_reset_t hw_context_destroy = nullptr; + struct retro_disk_control_callback *retro_disk_control_callback = nullptr; + + std::string savesDirectory; + std::string systemDirectory; + retro_hw_get_current_framebuffer_t callback_get_current_framebuffer = nullptr; + unsigned language = RETRO_LANGUAGE_ENGLISH; + bool useVirtualFileSystem = false; + bool enableMicrophone = false; + + int pixelFormat = RETRO_PIXEL_FORMAT_RGB565; + bool useHWAcceleration = false; + bool useDepth = false; + bool useStencil = false; + bool bottomLeftOrigin = false; + + float screenRotation = 0; + bool screenRotationUpdated = false; + + bool gameGeometryUpdated = false; + unsigned gameGeometryWidth = 0; + unsigned gameGeometryHeight = 0; + float gameGeometryAspectRatio = -1.0f; + + std::array rumbleStates; + + std::unordered_map variables; + bool dirtyVariables = false; + + std::vector> controllers; +}; + +struct Variable { +public: + std::string key; + std::string value; + std::string description; +}; + +struct Controller { +public: + unsigned id; + std::string description; +}; + +#endif //LIBRETRODROID_ENVIRONMENT_H + diff --git a/libretrodroid/src/main/cpp/errorcodes.cpp b/libretrodroid/src/main/cpp/errorcodes.cpp new file mode 100644 index 000000000..264069b22 --- /dev/null +++ b/libretrodroid/src/main/cpp/errorcodes.cpp @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2019 Filippo Scognamiglio + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef LIBRETRODROID_ERRORCODES_H +#define LIBRETRODROID_ERRORCODES_H + +namespace libretrodroid { + int ERROR_LOAD_LIBRARY = 0; + int ERROR_LOAD_GAME = 1; + int ERROR_GL_NOT_COMPATIBLE = 2; + int ERROR_SERIALIZATION = 3; + int ERROR_CHEAT = 4; + int ERROR_GENERIC = -1; +} + +#endif //LIBRETRODROID_ERRORCODES_H diff --git a/libretrodroid/src/main/cpp/errorcodes.h b/libretrodroid/src/main/cpp/errorcodes.h new file mode 100644 index 000000000..3524a7fc6 --- /dev/null +++ b/libretrodroid/src/main/cpp/errorcodes.h @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2019 Filippo Scognamiglio + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef LIBRETRODROID_ERRORCODES_H +#define LIBRETRODROID_ERRORCODES_H + +namespace libretrodroid { + extern int ERROR_LOAD_LIBRARY; + extern int ERROR_LOAD_GAME; + extern int ERROR_GL_NOT_COMPATIBLE; + extern int ERROR_SERIALIZATION; + extern int ERROR_CHEAT; + extern int ERROR_GENERIC; +} + +#endif //LIBRETRODROID_ERRORCODES_H diff --git a/libretrodroid/src/main/cpp/fpssync.cpp b/libretrodroid/src/main/cpp/fpssync.cpp new file mode 100644 index 000000000..ffbe156ec --- /dev/null +++ b/libretrodroid/src/main/cpp/fpssync.cpp @@ -0,0 +1,64 @@ +/* + * Copyright (C) 2019 Filippo Scognamiglio + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include +#include "fpssync.h" +#include "log.h" + +namespace libretrodroid { + +unsigned FPSSync::advanceFrames() { + if (useVSync) return 1; + + if (lastFrame == MIN_TIME) { + start(); + } + + auto now = std::chrono::steady_clock::now(); + auto frames = std::max((now - lastFrame) / sampleInterval, (long long) 1); + lastFrame = lastFrame + sampleInterval * frames; + + return frames; +} + +FPSSync::FPSSync(double contentRefreshRate, double screenRefreshRate) { + this->contentRefreshRate = contentRefreshRate; + this->screenRefreshRate = screenRefreshRate; + this->useVSync = std::abs(contentRefreshRate - screenRefreshRate) < FPS_TOLERANCE; + this->sampleInterval = std::chrono::microseconds((long) ((1000000L / contentRefreshRate))); + reset(); +} + +void FPSSync::start() { + LOGI("Starting game with fps %f on a screen with refresh rate %f. Using vsync: %d", contentRefreshRate, screenRefreshRate, useVSync); + lastFrame = std::chrono::steady_clock::now(); +} + +void FPSSync::reset() { + lastFrame = MIN_TIME; +} + +double FPSSync::getTimeStretchFactor() { + return useVSync ? contentRefreshRate / screenRefreshRate : 1.0; +} + +void FPSSync::wait() { + if (useVSync) return; + std::this_thread::sleep_until(lastFrame); +} + +} //namespace libretrodroid diff --git a/libretrodroid/src/main/cpp/fpssync.h b/libretrodroid/src/main/cpp/fpssync.h new file mode 100644 index 000000000..324937d3e --- /dev/null +++ b/libretrodroid/src/main/cpp/fpssync.h @@ -0,0 +1,55 @@ +/* + * Copyright (C) 2019 Filippo Scognamiglio + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef LIBRETRODROID_FPSSYNC_H +#define LIBRETRODROID_FPSSYNC_H + +#include +#include + +namespace libretrodroid { + +typedef std::chrono::steady_clock::time_point TimePoint; +typedef std::chrono::duration Duration; + +class FPSSync { +public: + FPSSync(double contentRefreshRate, double screenRefreshRate); + ~FPSSync() { } + + void reset(); + unsigned advanceFrames(); + void wait(); + double getTimeStretchFactor(); +private: + + double screenRefreshRate; + double contentRefreshRate; + bool useVSync; + const double FPS_TOLERANCE = 5; + + const TimePoint MIN_TIME = TimePoint::min(); + void start(); + + TimePoint lastFrame = MIN_TIME; + Duration sampleInterval; +}; + +} + + +#endif //LIBRETRODROID_FPSSYNC_H diff --git a/libretrodroid/src/main/cpp/immersivemode.cpp b/libretrodroid/src/main/cpp/immersivemode.cpp new file mode 100644 index 000000000..af9235010 --- /dev/null +++ b/libretrodroid/src/main/cpp/immersivemode.cpp @@ -0,0 +1,286 @@ +/* + * Copyright (C) 2025 Filippo Scognamiglio + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "immersivemode.h" + +#include +#include + +#include "log.h" + +namespace libretrodroid { + +void ImmersiveMode::initializeShaders() { + if (blendShaderProgram != 0) return; + + GLuint blendingVertexShader = glCreateShader(GL_VERTEX_SHADER); + glShaderSource(blendingVertexShader, 1, &blendingVertexShaderSource, nullptr); + glCompileShader(blendingVertexShader); + + GLuint blendingFragmentShader = glCreateShader(GL_FRAGMENT_SHADER); + glShaderSource(blendingFragmentShader, 1, &blendingFragmentShaderSource, nullptr); + glCompileShader(blendingFragmentShader); + + blendShaderProgram = glCreateProgram(); + glAttachShader(blendShaderProgram, blendingVertexShader); + glAttachShader(blendShaderProgram, blendingFragmentShader); + glBindAttribLocation(blendShaderProgram, 0, "aPosition"); + glBindAttribLocation(blendShaderProgram, 1, "aTexCoord"); + glLinkProgram(blendShaderProgram); + + glDeleteShader(blendingVertexShader); + glDeleteShader(blendingFragmentShader); + + blendTextureHandle = glGetUniformLocation(blendShaderProgram, "currentFrame"); + blendPrevTextureHandle = glGetUniformLocation(blendShaderProgram, "previousFrame"); + blendFactorHandle = glGetUniformLocation(blendShaderProgram, "blendFactor"); + + std::string fragmentShaderSource = generateBlurShader(); + + GLuint vertexShader = glCreateShader(GL_VERTEX_SHADER); + glShaderSource(vertexShader, 1, &defaultVertexShaderSource, nullptr); + glCompileShader(vertexShader); + + GLuint fragmentShader = glCreateShader(GL_FRAGMENT_SHADER); + const char* c_str = fragmentShaderSource.c_str(); + glShaderSource(fragmentShader, 1, &c_str, nullptr); + glCompileShader(fragmentShader); + + blurShaderProgram = glCreateProgram(); + glAttachShader(blurShaderProgram, vertexShader); + glAttachShader(blurShaderProgram, fragmentShader); + glBindAttribLocation(blurShaderProgram, 0, "aPosition"); + glBindAttribLocation(blurShaderProgram, 1, "aTexCoord"); + glLinkProgram(blurShaderProgram); + + glDeleteShader(vertexShader); + glDeleteShader(fragmentShader); + + blurPositionHandle = glGetAttribLocation(blurShaderProgram, "aPosition"); + blurTextureCoordinatesHandle = glGetAttribLocation(blurShaderProgram, "aTexCoord"); + blurTextureHandle = glGetUniformLocation(blurShaderProgram, "texture"); + blurDirectionHandle = glGetUniformLocation(blurShaderProgram, "direction"); + + GLuint displayVertexShader = glCreateShader(GL_VERTEX_SHADER); + glShaderSource(displayVertexShader, 1, &defaultVertexShaderSource, nullptr); + glCompileShader(displayVertexShader); + + GLuint displayFragmentShader = glCreateShader(GL_FRAGMENT_SHADER); + glShaderSource(displayFragmentShader, 1, &displayFragmentShaderSource, nullptr); + glCompileShader(displayFragmentShader); + + displayShaderProgram = glCreateProgram(); + glAttachShader(displayShaderProgram, displayVertexShader); + glAttachShader(displayShaderProgram, displayFragmentShader); + glBindAttribLocation(displayShaderProgram, 0, "aPosition"); + glBindAttribLocation(displayShaderProgram, 1, "aTexCoord"); + glLinkProgram(displayShaderProgram); + + glDeleteShader(displayVertexShader); + glDeleteShader(displayFragmentShader); + + displayPositionHandle = glGetAttribLocation(blurShaderProgram, "aPosition"); + displayForegroundBoundsHandle = glGetUniformLocation(displayShaderProgram, "foregroundBounds"); + displayTextureCoordinatesHandle = glGetAttribLocation(blurShaderProgram, "aTexCoord"); + displayTextureHandle = glGetUniformLocation(displayShaderProgram, "texture"); +} + +void ImmersiveMode::initializeFramebuffers() { + if (!blurFramebuffers.empty()) return; + + for (int i = 0; i < 3; i++) { + blurFramebuffers.push_back( + ES3Utils::createFramebuffer( + downscaledWidth, downscaledHeight, true, false, false, false + ) + ); + } + + // Last framebuffer needs GL_MIRROR_REPEAT + blurFramebuffers.push_back( + ES3Utils::createFramebuffer( + downscaledWidth, downscaledHeight, true, true, false, false + ) + ); +} + +void ImmersiveMode::renderToFramebuffer(uintptr_t texture, GLfloat* gBackgroundVertices) { + int blendFramebufferReadIndex = (blendFramebufferWriteIndex + 1) % 2; + + glViewport(0, 0, downscaledWidth, downscaledHeight); + + glVertexAttribPointer(blurPositionHandle, 2, GL_FLOAT, GL_FALSE, 0, gBackgroundVertices); + glEnableVertexAttribArray(blurPositionHandle); + + glVertexAttribPointer(blurTextureCoordinatesHandle, 2, GL_FLOAT, GL_FALSE, 0, gTextureCoords); + glEnableVertexAttribArray(blurTextureCoordinatesHandle); + + glBindFramebuffer(GL_FRAMEBUFFER, blurFramebuffers[blendFramebufferWriteIndex]->framebuffer); + + glClear(GL_COLOR_BUFFER_BIT); + + glUseProgram(blendShaderProgram); + glUniform1f(blendFactorHandle, blendFactor); + + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, texture); + glUniform1i(blendTextureHandle, 0); + + glActiveTexture(GL_TEXTURE1); + glBindTexture(GL_TEXTURE_2D, blurFramebuffers[blendFramebufferReadIndex]->texture); + glUniform1i(blendPrevTextureHandle, 1); + + glDrawArrays(GL_TRIANGLES, 0, 6); + + glUseProgram(blurShaderProgram); + + glBindFramebuffer(GL_FRAMEBUFFER, blurFramebuffers[2]->framebuffer); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, blurFramebuffers[blendFramebufferWriteIndex]->texture); + glUniform1i(blurTextureHandle, 0); + glUniform2f(blurDirectionHandle, 1.0 / downscaledWidth, 0.0); + + glDrawArrays(GL_TRIANGLES, 0, 6); + + glBindFramebuffer(GL_FRAMEBUFFER, blurFramebuffers[3]->framebuffer); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, blurFramebuffers[2]->texture); + glUniform1i(blurTextureHandle, 0); + glUniform2f(blurDirectionHandle, 0.0, 1.0 / downscaledHeight); + + glDrawArrays(GL_TRIANGLES, 0, 6); + + blendFramebufferWriteIndex = blendFramebufferReadIndex; +} + +void ImmersiveMode::renderToFinalOutput( + unsigned screenWidth, + unsigned screenHeight, + std::array backgroundVertices, + std::array foregroundBounds +) { + glBindFramebuffer(GL_FRAMEBUFFER, 0); + glViewport(0, 0, screenWidth, screenHeight); + glUseProgram(displayShaderProgram); + + glVertexAttribPointer(displayPositionHandle, 2, GL_FLOAT, GL_FALSE, 0, backgroundVertices.data()); + glEnableVertexAttribArray(displayPositionHandle); + + glVertexAttribPointer(displayTextureCoordinatesHandle, 2, GL_FLOAT, GL_FALSE, 0, gTextureCoords); + glEnableVertexAttribArray(displayTextureCoordinatesHandle); + + glUniform4f( + displayForegroundBoundsHandle, + foregroundBounds[0], + foregroundBounds[1], + foregroundBounds[2], + foregroundBounds[3] + ); + + glUniform1i(displayTextureHandle, 0); + + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, blurFramebuffers[3]->texture); + glUniform1i(displayTextureHandle, 0); + glDrawArrays(GL_TRIANGLES, 0, 6); + + glDisableVertexAttribArray(displayPositionHandle); + glDisableVertexAttribArray(displayTextureCoordinatesHandle); + glBindBuffer(GL_ARRAY_BUFFER, 0); + glBindTexture(GL_TEXTURE_2D, 0); + glUseProgram(0); +} + +void ImmersiveMode::renderBackground( + unsigned screenWidth, + unsigned screenHeight, + std::array backgroundVertices, + std::array foregroundBounds, + GLfloat* framebufferVertices, + uintptr_t texture +) { + initializeShaders(); + initializeFramebuffers(); + + if (blendFramebufferCurrent == 0) { + renderToFramebuffer(texture, framebufferVertices); + } + + // Let's update the blurred texture every other frame. + blendFramebufferCurrent = (blendFramebufferCurrent + 1) % blurSkipUpdate; + + renderToFinalOutput(screenWidth, screenHeight, backgroundVertices, foregroundBounds); +} + +std::vector ImmersiveMode::generateSmoothingWeights(int size, float brightness) { + std::vector kernel(size); + float sigma = size / 3.0f; + float sum = 0.0f; + int halfSize = size / 2; + + for (int i = 0; i < size; i++) { + int x = i - halfSize; + kernel[i] = exp(-(x * x) / (2 * sigma * sigma)); + sum += kernel[i]; + } + + for (int i = 0; i < size; i++) { + kernel[i] = (kernel[i] / sum) * brightness; + } + + return kernel; +} + +std::string ImmersiveMode::generateBlurShader() { + if (blurMaskSize % 2 == 0) { + LOGE("Error: maskSize should be an odd number!"); + return ""; + } + + std::vector kernel = generateSmoothingWeights(blurMaskSize, sqrt(blurBrightness)); + + std::ostringstream result; + result << R"( + precision mediump float; + varying vec2 vTexCoord; + uniform sampler2D texture; + uniform vec2 direction; + + void main() { + lowp vec4 result = vec4(0.0); + lowp float kernel[)" << blurMaskSize << R"(]; + + )"; + + for (int i = 0; i < blurMaskSize; i++) { + result << " kernel[" << i << "] = " << kernel[i] << ";\n"; + } + + int halfMask = blurMaskSize / 2; + result << R"( + for (int i = -)" << halfMask << "; i <= " << halfMask << R"(; i++) { + lowp vec2 offset = vec2(float(i)) * direction; + result += texture2D(texture, vTexCoord + offset) * kernel[i + )" << halfMask << R"(]; + } + gl_FragColor = result; + } + )"; + + return result.str(); +} + +} // namespace libretrodroid diff --git a/libretrodroid/src/main/cpp/immersivemode.h b/libretrodroid/src/main/cpp/immersivemode.h new file mode 100644 index 000000000..50f066ee8 --- /dev/null +++ b/libretrodroid/src/main/cpp/immersivemode.h @@ -0,0 +1,200 @@ +/* + * Copyright (C) 2025 Filippo Scognamiglio + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +#ifndef LIBRETRODROID_IMMERSIVEMODE_H +#define LIBRETRODROID_IMMERSIVEMODE_H + +#include +#include +#include +#include +#include + +#include "renderers/es3/es3utils.h" + +namespace libretrodroid { + +class ImmersiveMode { +public: + struct Config { + Config( + int downscaledWidth = 8, + int downscaledHeight = 8, + int blurMaskSize = 5, + float blurBrightness = 0.5F, + int blurSkipUpdate = 2, + float blendFactor = 0.1F + ) : + downscaledWidth(downscaledWidth), + downscaledHeight(downscaledHeight), + blurMaskSize(blurMaskSize), + blurBrightness(blurBrightness), + blurSkipUpdate(blurSkipUpdate), + blendFactor(blendFactor) + {} + + int downscaledWidth; + int downscaledHeight; + int blurMaskSize; + float blurBrightness; + int blurSkipUpdate; + float blendFactor; + }; + + explicit ImmersiveMode(const Config& config = Config()) : + downscaledWidth(config.downscaledWidth), + downscaledHeight(config.downscaledHeight), + blurMaskSize(config.blurMaskSize), + blurBrightness(config.blurBrightness), + blurSkipUpdate(config.blurSkipUpdate > 0 ? config.blurSkipUpdate : 1), + blendFactor(config.blendFactor) + {} + + void renderBackground( + unsigned screenWidth, + unsigned screenHeight, + std::array backgroundVertices, + std::array foregroundBounds, + GLfloat* framebufferVertices, + uintptr_t texture + ); + +private: + std::string generateBlurShader(); + static std::vector generateSmoothingWeights(int size, float brightness); + + void initializeShaders(); + + void initializeFramebuffers(); + + void renderToFramebuffer(uintptr_t texture, GLfloat* gBackgroundVertices); + + void renderToFinalOutput( + unsigned screenWidth, + unsigned screenHeight, + std::array backgroundVertices, + std::array foregroundBounds + ); + +private: + const char* defaultVertexShaderSource = R"( + attribute mediump vec2 aPosition; + attribute mediump vec2 aTexCoord; + varying mediump vec2 vTexCoord; + void main() { + gl_Position = vec4(aPosition, 0.0, 1.0); + vTexCoord = aTexCoord; + } + )"; + + const char* displayFragmentShaderSource = R"( + precision mediump float; + varying mediump vec2 vTexCoord; + uniform lowp sampler2D texture; + uniform vec4 foregroundBounds; // (minX, minY, maxX, maxY) + + void main() { + mediump vec2 normalizedCoords = (vTexCoord - foregroundBounds.xy) / abs(foregroundBounds.zw - foregroundBounds.xy); + lowp vec2 speed = abs(foregroundBounds.zw - foregroundBounds.xy); + + mediump vec2 adjustedCoords = vec2(0.0); + adjustedCoords -= speed * normalizedCoords; + adjustedCoords += (1.0 + speed) * step(vec2(0.0), normalizedCoords) * normalizedCoords; + adjustedCoords -= (1.0 - speed) * step(vec2(1.0), normalizedCoords) * (normalizedCoords - 1.0); + + gl_FragColor = texture2D(texture, adjustedCoords); + } + )"; + + const char* blendingVertexShaderSource = R"( + attribute mediump vec2 aPosition; + attribute mediump vec2 aTexCoord; + varying mediump vec2 vTexCoord; + void main() { + gl_Position = vec4(aPosition, 0.0, 1.0); + vTexCoord = aTexCoord; + } + )"; + + const char* blendingFragmentShaderSource = R"( + precision mediump float; + varying mediump vec2 vTexCoord; + uniform lowp sampler2D currentFrame; + uniform lowp sampler2D previousFrame; + uniform float blendFactor; + + void main() { + lowp float margin = -0.125; + mediump vec2 adjustedCoord = vTexCoord * (1.0 - 2.0 * margin) + margin; + lowp vec4 currentColor = texture2D(currentFrame, adjustedCoord); + lowp vec4 prevColor = texture2D(previousFrame, vTexCoord); + gl_FragColor = mix(prevColor, currentColor, blendFactor); + } + )"; + + GLfloat gTextureCoords[12] = { + 0.0F, + 0.0F, + + 0.0F, + 1.0F, + + 1.0F, + 0.0F, + + 1.0F, + 0.0F, + + 0.0F, + 1.0F, + + 1.0F, + 1.0F, + }; + + GLuint blurShaderProgram = 0; + std::vector> blurFramebuffers; + + GLint blurPositionHandle = -1; + GLint blurTextureCoordinatesHandle = -1; + GLint blurTextureHandle = -1; + + GLint blurDirectionHandle = -1; + + GLuint displayShaderProgram = 0; + GLint displayTextureHandle = -1; + GLint displayPositionHandle = -1; + GLint displayForegroundBoundsHandle = -1; + GLint displayTextureCoordinatesHandle = -1; + + int downscaledWidth; + int downscaledHeight; + int blurMaskSize; + float blurBrightness; + int blurSkipUpdate; + float blendFactor; + + GLuint blendShaderProgram = 0; + GLint blendTextureHandle = -1; + GLint blendPrevTextureHandle = -1; + GLint blendFactorHandle = -1; + int blendFramebufferWriteIndex = 0; + int blendFramebufferCurrent = 0; +}; + +} // libretrodroid + +#endif //LIBRETRODROID_IMMERSIVEMODE_H diff --git a/libretrodroid/src/main/cpp/input.cpp b/libretrodroid/src/main/cpp/input.cpp new file mode 100644 index 000000000..f52cec742 --- /dev/null +++ b/libretrodroid/src/main/cpp/input.cpp @@ -0,0 +1,227 @@ +/* + * Copyright (C) 2019 Filippo Scognamiglio + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "input.h" + +#include + +#include +#include + +#include "../../libretro-common/include/libretro.h" + +namespace libretrodroid { + +int16_t Input::getInputState(unsigned port, unsigned device, unsigned index, unsigned id) { + if (port >= 4 || port < 0) return 0; + + switch (device) { + case RETRO_DEVICE_JOYPAD: { + switch (id) { + case RETRO_DEVICE_ID_JOYPAD_LEFT: { + bool axis = pads[port].dpadXAxis == -1; + bool buttons = anyPressed( + port, + RETRO_DEVICE_ID_JOYPAD_LEFT, + Input::RETRO_DEVICE_ID_JOYPAD_DOWN_LEFT, + Input::RETRO_DEVICE_ID_JOYPAD_UP_LEFT + ); + return axis || buttons; + } + case RETRO_DEVICE_ID_JOYPAD_RIGHT: { + bool axis = pads[port].dpadXAxis == 1; + bool buttons = anyPressed( + port, + RETRO_DEVICE_ID_JOYPAD_RIGHT, + Input::RETRO_DEVICE_ID_JOYPAD_UP_RIGHT, + Input::RETRO_DEVICE_ID_JOYPAD_DOWN_RIGHT + ); + return axis || buttons; + } + case RETRO_DEVICE_ID_JOYPAD_UP: { + bool axis = pads[port].dpadYAxis == -1; + bool buttons = anyPressed( + port, + RETRO_DEVICE_ID_JOYPAD_UP, + Input::RETRO_DEVICE_ID_JOYPAD_UP_LEFT, + Input::RETRO_DEVICE_ID_JOYPAD_UP_RIGHT + ); + return axis || buttons; + } + case RETRO_DEVICE_ID_JOYPAD_DOWN: { + bool axis = pads[port].dpadYAxis == 1; + bool buttons = anyPressed( + port, + RETRO_DEVICE_ID_JOYPAD_DOWN, + Input::RETRO_DEVICE_ID_JOYPAD_DOWN_LEFT, + Input::RETRO_DEVICE_ID_JOYPAD_DOWN_RIGHT + ); + return axis || buttons; + } + default: + return anyPressed(port, id); + } + } + + case RETRO_DEVICE_ANALOG: { + switch (index) { + case RETRO_DEVICE_INDEX_ANALOG_LEFT: + switch (id) { + case RETRO_DEVICE_ID_ANALOG_X: + return (int16_t) (pads[port].joypadLeftXAxis * MAX_RANGE_MOTION); + case RETRO_DEVICE_ID_ANALOG_Y: + return (int16_t) (pads[port].joypadLeftYAxis * MAX_RANGE_MOTION); + default: + return 0; + } + case RETRO_DEVICE_INDEX_ANALOG_RIGHT: + switch (id) { + case RETRO_DEVICE_ID_ANALOG_X: + return (int16_t) (pads[port].joypadRightXAxis * MAX_RANGE_MOTION); + case RETRO_DEVICE_ID_ANALOG_Y: + return (int16_t) (pads[port].joypadRightYAxis * MAX_RANGE_MOTION); + default: + return 0; + } + default: + return 0; + } + } + + case RETRO_DEVICE_POINTER: { + // TODO... Here we should hanlde multitouch... + if (index > 0) { + return 0; + } + + switch (id) { + case RETRO_DEVICE_ID_POINTER_PRESSED: { + bool isXActive = pads[port].pointerScreenXAxis >= 0; + bool isYActive = pads[port].pointerScreenYAxis >= 0; + return (int16_t) (isXActive && isYActive ? 1 : 0); + } + + case RETRO_DEVICE_ID_POINTER_X: + return (int16_t) (2.0 * (pads[port].pointerScreenXAxis - 0.5f) * MAX_RANGE_MOTION); + + case RETRO_DEVICE_ID_POINTER_Y: + return (int16_t) (2.0 * (pads[port].pointerScreenYAxis - 0.5f) * MAX_RANGE_MOTION); + + default: + return 0; + } + } + + default: + return 0; + } +} + +int Input::convertAndroidToLibretroKey(int keyCode) const { + switch (keyCode) { + case AKEYCODE_BUTTON_START: + return RETRO_DEVICE_ID_JOYPAD_START; + case AKEYCODE_BUTTON_SELECT: + return RETRO_DEVICE_ID_JOYPAD_SELECT; + case AKEYCODE_BUTTON_A: + return RETRO_DEVICE_ID_JOYPAD_A; + case AKEYCODE_BUTTON_X: + return RETRO_DEVICE_ID_JOYPAD_X; + case AKEYCODE_BUTTON_Y: + return RETRO_DEVICE_ID_JOYPAD_Y; + case AKEYCODE_BUTTON_B: + return RETRO_DEVICE_ID_JOYPAD_B; + case AKEYCODE_BUTTON_L1: + return RETRO_DEVICE_ID_JOYPAD_L; + case AKEYCODE_BUTTON_L2: + return RETRO_DEVICE_ID_JOYPAD_L2; + case AKEYCODE_BUTTON_R1: + return RETRO_DEVICE_ID_JOYPAD_R; + case AKEYCODE_BUTTON_R2: + return RETRO_DEVICE_ID_JOYPAD_R2; + case AKEYCODE_BUTTON_THUMBL: + return RETRO_DEVICE_ID_JOYPAD_L3; + case AKEYCODE_BUTTON_THUMBR: + return RETRO_DEVICE_ID_JOYPAD_R3; + case AKEYCODE_DPAD_UP: + return RETRO_DEVICE_ID_JOYPAD_UP; + case AKEYCODE_DPAD_DOWN: + return RETRO_DEVICE_ID_JOYPAD_DOWN; + case AKEYCODE_DPAD_LEFT: + return RETRO_DEVICE_ID_JOYPAD_LEFT; + case AKEYCODE_DPAD_RIGHT: + return RETRO_DEVICE_ID_JOYPAD_RIGHT; + case AKEYCODE_DPAD_UP_RIGHT: + return Input::RETRO_DEVICE_ID_JOYPAD_UP_RIGHT; + case AKEYCODE_DPAD_UP_LEFT: + return Input::RETRO_DEVICE_ID_JOYPAD_UP_LEFT; + case AKEYCODE_DPAD_DOWN_RIGHT: + return Input::RETRO_DEVICE_ID_JOYPAD_DOWN_RIGHT; + case AKEYCODE_DPAD_DOWN_LEFT: + return Input::RETRO_DEVICE_ID_JOYPAD_DOWN_LEFT; + default: + return UNKNOWN_KEY; + } +} + +void Input::onKeyEvent(unsigned int port, int action, int keyCode) { + int retroKeyCode = convertAndroidToLibretroKey(keyCode); + if (retroKeyCode == UNKNOWN_KEY) { + return; + } + + if (action == AKEY_EVENT_ACTION_DOWN) { + pads[port].pressedKeys.insert(retroKeyCode); + } else if (action == AKEY_EVENT_ACTION_UP) { + pads[port].pressedKeys.erase(retroKeyCode); + } +} + +void Input::onMotionEvent(int port, int motionSource, float xAxis, float yAxis) { + switch (motionSource) { + case Input::MOTION_SOURCE_DPAD: + pads[port].dpadXAxis = (int) round(xAxis); + pads[port].dpadYAxis = (int) round(yAxis); + break; + + case Input::MOTION_SOURCE_ANALOG_LEFT: + pads[port].joypadLeftXAxis = xAxis; + pads[port].joypadLeftYAxis = yAxis; + break; + + case Input::MOTION_SOURCE_ANALOG_RIGHT: + pads[port].joypadRightXAxis = xAxis; + pads[port].joypadRightYAxis = yAxis; + break; + + case Input::MOTION_SOURCE_POINTER: + pads[port].pointerScreenXAxis = xAxis; + pads[port].pointerScreenYAxis = yAxis; + break; + } +} + +template +bool Input::anyPressed(unsigned int port, unsigned int id, T &... args) const { + return anyPressed(port, id) || anyPressed(port, args...); +} + +bool Input::anyPressed(unsigned int port, unsigned int id) const { + return pads[port].pressedKeys.count(id) > 0; +} + +} //namespace libretrodroid diff --git a/libretrodroid/src/main/cpp/input.h b/libretrodroid/src/main/cpp/input.h new file mode 100644 index 000000000..9d7c44b1e --- /dev/null +++ b/libretrodroid/src/main/cpp/input.h @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2019 Filippo Scognamiglio + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#ifndef LIBRETRODROID_INPUT_H +#define LIBRETRODROID_INPUT_H + +#include +#include +#include + +namespace libretrodroid { + +class Input { + +private: + struct GamePadState { + std::unordered_set pressedKeys; + + int dpadXAxis = 0; + int dpadYAxis = 0; + float joypadLeftXAxis = 0; + float joypadLeftYAxis = 0; + float joypadRightXAxis = 0; + float joypadRightYAxis = 0; + float pointerScreenXAxis = -1; + float pointerScreenYAxis = -1; + }; + +public: + static constexpr int MOTION_SOURCE_DPAD = 0; + static constexpr int MOTION_SOURCE_ANALOG_LEFT = 1; + static constexpr int MOTION_SOURCE_ANALOG_RIGHT = 2; + static constexpr int MOTION_SOURCE_POINTER = 3; + static constexpr int MAX_RANGE_MOTION = 0x7fff; + + static constexpr int RETRO_DEVICE_ID_JOYPAD_UP_LEFT = 50; + static constexpr int RETRO_DEVICE_ID_JOYPAD_UP_RIGHT = 51; + static constexpr int RETRO_DEVICE_ID_JOYPAD_DOWN_LEFT = 52; + static constexpr int RETRO_DEVICE_ID_JOYPAD_DOWN_RIGHT = 53; + + int16_t getInputState(unsigned port, unsigned device, unsigned index, unsigned id); + + void onKeyEvent(unsigned int port, int action, int keyCode); + void onMotionEvent(int port, int motionSource, float xAxis, float yAxis); + +private: + const int UNKNOWN_KEY = -1; + + template + bool anyPressed(unsigned int port, unsigned int id, T&... args) const; + bool anyPressed(unsigned int port, unsigned int id) const; + int convertAndroidToLibretroKey(int keyCode) const; + + GamePadState pads[4]; +}; + +} + +#endif //LIBRETRODROID_INPUT_H diff --git a/libretrodroid/src/main/cpp/libretro/libretro-common b/libretrodroid/src/main/cpp/libretro/libretro-common new file mode 160000 index 000000000..b0c348ea5 --- /dev/null +++ b/libretrodroid/src/main/cpp/libretro/libretro-common @@ -0,0 +1 @@ +Subproject commit b0c348ea5543c4d7fb0bc479258aa6988b20c0c9 diff --git a/libretrodroid/src/main/cpp/libretrodroid.cpp b/libretrodroid/src/main/cpp/libretrodroid.cpp new file mode 100644 index 000000000..60640c47c --- /dev/null +++ b/libretrodroid/src/main/cpp/libretrodroid.cpp @@ -0,0 +1,654 @@ +/* + * Copyright (C) 2019 Filippo Scognamiglio + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include + +#include + +#include +#include +#include +#include + +#include "libretrodroid.h" +#include "utils/libretrodroidexception.h" +#include "log.h" +#include "core.h" +#include "audio.h" +#include "video.h" +#include "renderers/renderer.h" +#include "fpssync.h" +#include "input.h" +#include "rumble.h" +#include "shadermanager.h" +#include "utils/javautils.h" +#include "environment.h" +#include "renderers/es3/framebufferrenderer.h" +#include "renderers/es2/imagerendereres2.h" +#include "renderers/es3/imagerendereres3.h" +#include "utils/utils.h" +#include "utils/rect.h" +#include "errorcodes.h" +#include "vfs/vfs.h" + +namespace libretrodroid { + +uintptr_t LibretroDroid::callback_get_current_framebuffer() { + return LibretroDroid::getInstance().handleGetCurrentFrameBuffer(); +} + +void LibretroDroid::callback_hw_video_refresh( + const void *data, + unsigned width, + unsigned height, + size_t pitch +) { + LOGD("hw video refresh callback called %i %i", width, height); + LibretroDroid::getInstance().handleVideoRefresh(data, width, height, pitch); +} + +void LibretroDroid::callback_audio_sample(int16_t left, int16_t right) { + LOGE("callback audio sample (left, right) has been called"); +} + +size_t LibretroDroid::callback_set_audio_sample_batch(const int16_t *data, size_t frames) { + return LibretroDroid::getInstance().handleAudioCallback(data, frames); +} + +void LibretroDroid::callback_retro_set_input_poll() { + // Do nothing in here... +} + +int16_t LibretroDroid::callback_set_input_state( + unsigned int port, + unsigned int device, + unsigned int index, + unsigned int id +) { + return LibretroDroid::getInstance().handleSetInputState(port, device, index, id); +} + +void LibretroDroid::updateAudioSampleRateMultiplier() { + if (audio) { + audio->setPlaybackSpeed(frameSpeed); + } +} + +// TODO... Do we really need this? +void LibretroDroid::resetGlobalVariables() { + core = nullptr; + audio = nullptr; + video = nullptr; + fpsSync = nullptr; + input = nullptr; + rumble = nullptr; +} + +int LibretroDroid::availableDisks() { + return Environment::getInstance().getRetroDiskControlCallback() != nullptr + ? Environment::getInstance().getRetroDiskControlCallback()->get_num_images() + : 0; +} + +int LibretroDroid::currentDisk() { + return Environment::getInstance().getRetroDiskControlCallback() != nullptr + ? Environment::getInstance().getRetroDiskControlCallback()->get_image_index() + : 0; +} + +void LibretroDroid::changeDisk(unsigned int index) { + if (Environment::getInstance().getRetroDiskControlCallback() == nullptr) { + LOGE("Cannot swap disk. This platform does not support it."); + return; + } + + if (index < 0 || index >= Environment::getInstance().getRetroDiskControlCallback()->get_num_images()) { + LOGE("Requested image index is not valid."); + return; + } + + if (Environment::getInstance().getRetroDiskControlCallback()->get_image_index() != index) { + Environment::getInstance().getRetroDiskControlCallback()->set_eject_state(true); + Environment::getInstance().getRetroDiskControlCallback()->set_image_index((unsigned) index); + Environment::getInstance().getRetroDiskControlCallback()->set_eject_state(false); + } +} + +void LibretroDroid::updateVariable(const Variable& variable) { + Environment::getInstance().updateVariable(variable.key, variable.value); +} + +std::vector LibretroDroid::getVariables() { + return Environment::getInstance().getVariables(); +} + +std::vector> LibretroDroid::getControllers() { + return Environment::getInstance().getControllers(); +} + +void LibretroDroid::setControllerType(unsigned int port, unsigned int type) { + core->retro_set_controller_port_device(port, type); +} + +bool LibretroDroid::unserializeState(int8_t *data, size_t size) { + std::lock_guard lock(coreLock); + + return core->retro_unserialize(data, size); +} + +JNIEXPORT jboolean JNICALL LibretroDroid::unserializeSRAM(int8_t* data, size_t size) { + std::lock_guard lock(coreLock); + + size_t sramSize = core->retro_get_memory_size(RETRO_MEMORY_SAVE_RAM); + void *sramState = core->retro_get_memory_data(RETRO_MEMORY_SAVE_RAM); + + if (sramState == nullptr) { + LOGE("Cannot load SRAM: nullptr in retro_get_memory_data"); + return false; + } + + if (size > sramSize) { + LOGE("Cannot load SRAM: size mismatch"); + return false; + } + + memcpy(sramState, data, size); + + return true; +} + +std::pair LibretroDroid::serializeSRAM() { + std::lock_guard lock(coreLock); + + size_t size = core->retro_get_memory_size(RETRO_MEMORY_SAVE_RAM); + auto* data = new int8_t[size]; + memcpy(data, (int8_t*) core->retro_get_memory_data(RETRO_MEMORY_SAVE_RAM), size); + + return std::pair(data, size); +} + +void LibretroDroid::onSurfaceChanged(unsigned int width, unsigned int height) { + LOGD("Performing libretrodroid onSurfaceChanged"); + video->updateScreenSize(width, height); +} + +void LibretroDroid::onSurfaceCreated() { + LOGD("Performing libretrodroid onSurfaceCreated"); + + struct retro_system_av_info system_av_info {}; + core->retro_get_system_av_info(&system_av_info); + + video = nullptr; + + Video::RenderingOptions renderingOptions { + Environment::getInstance().isUseHwAcceleration(), + system_av_info.geometry.base_width, + system_av_info.geometry.base_height, + Environment::getInstance().isUseDepth(), + Environment::getInstance().isUseStencil(), + openglESVersion, + Environment::getInstance().getPixelFormat() + }; + + auto newVideo = new Video( + renderingOptions, + fragmentShaderConfig, + Environment::getInstance().isBottomLeftOrigin(), + Environment::getInstance().getScreenRotation(), + skipDuplicateFrames, + immersiveModeEnabled, + viewportRect, + immersiveModeConfig, + viewportAlignment + ); + + video = std::unique_ptr