Android 2.6.1: fix controller remap regression, Black/OLED themes, Boot BIOS, menu scroll memory, RA file-type badge, sub-native resolution fix, achievement-sound reliability, GameDB (Tekken 4/5/Tag, Ar Tonelico 1&2, Aeon Flux, Musashi)

This commit is contained in:
jpolo1224
2026-07-14 10:40:16 -04:00
parent 33c3f5a2cb
commit c305a647eb
14 changed files with 213 additions and 35 deletions
@@ -2972,6 +2972,9 @@ SCCS-60002:
SCED-50041:
name: "Tekken Tag Tournament [Demo]"
region: "PAL-E"
clampModes:
eeClampMode: 3 # Fixes camera and stops constant coin noises on Pirates Cove.
vu1ClampMode: 2 # Fixes broken effects.
gsHWFixes:
alignSprite: 1 # Fixes vertical lines.
SCED-50065:
@@ -3282,6 +3285,9 @@ SCED-51111:
SCED-51120:
name: "Tekken 4 [Demo]"
region: "PAL-E"
clampModes:
eeClampMode: 3 # Fixes camera and stops constant coin noises on Pirates Cove.
vu1ClampMode: 2 # Fixes broken effects.
gsHWFixes:
alignSprite: 1 # Fixes vertical lines.
SCED-51140:
@@ -4834,6 +4840,9 @@ SCES-50001:
name: "Tekken Tag Tournament"
region: "PAL-M5"
compat: 5
clampModes:
eeClampMode: 3 # Fixes camera and stops constant coin noises on Pirates Cove.
vu1ClampMode: 2 # Fixes broken effects.
gsHWFixes:
alignSprite: 1 # Fixes vertical lines.
SCES-50002:
@@ -5205,6 +5214,9 @@ SCES-50878:
name: "Tekken 4"
region: "PAL-M5"
compat: 5
clampModes:
eeClampMode: 3 # Fixes camera and stops constant coin noises on Pirates Cove.
vu1ClampMode: 2 # Fixes broken effects.
gsHWFixes:
alignSprite: 1 # Fixes vertical lines.
SCES-50885:
@@ -7645,6 +7657,9 @@ SCKA-20045:
name: "철권  [PlayStation 2 Big Hit Series]"
name-en: "Tekken 4 [PlayStation 2 Big Hit Series]"
region: "NTSC-K"
clampModes:
eeClampMode: 3 # Fixes camera and stops constant coin noises on Pirates Cove.
vu1ClampMode: 2 # Fixes broken effects.
gsHWFixes:
alignSprite: 1 # Fixes vertical lines.
SCKA-20046:
@@ -10798,6 +10813,9 @@ SCPS-55017:
name-en: "Tekken 4"
region: "NTSC-J"
compat: 3
clampModes:
eeClampMode: 3 # Fixes camera and stops constant coin noises on Pirates Cove.
vu1ClampMode: 2 # Fixes broken effects.
gsHWFixes:
alignSprite: 1
SCPS-55018:
@@ -11085,6 +11103,9 @@ SCPS-56002:
name-en: "Tekken Tag Tournament"
region: "NTSC-K"
compat: 5
clampModes:
eeClampMode: 3 # Fixes camera and stops constant coin noises on Pirates Cove.
vu1ClampMode: 2 # Fixes broken effects.
gsHWFixes:
alignSprite: 1 # Fixes vertical lines.
SCPS-56003:
@@ -11112,6 +11133,9 @@ SCPS-56006:
name-en: "Tekken 4"
region: "NTSC-K"
compat: 5
clampModes:
eeClampMode: 3 # Fixes camera and stops constant coin noises on Pirates Cove.
vu1ClampMode: 2 # Fixes broken effects.
gsHWFixes:
alignSprite: 1
SCPS-56007:
@@ -23955,6 +23979,8 @@ SLES-53518:
SLES-53521:
name: "Musashi - Samurai Legend"
region: "PAL-M4"
gsHWFixes:
mergeSprite: 1 # Fixes ghosting.
SLES-53523:
name: "Gun"
region: "PAL-M4"
@@ -25442,6 +25468,8 @@ SLES-53955:
SLES-53956:
name: "Aeon Flux"
region: "PAL-E"
speedHacks:
eeCycleRate: 1 # Improves performance.
compat: 5
SLES-53957:
name: "Namco Museum - 50th Anniversary"
@@ -26089,6 +26117,8 @@ SLES-54168:
SLES-54169:
name: "Aeon Flux"
region: "PAL-M4"
speedHacks:
eeCycleRate: 1 # Improves performance.
SLES-54170:
name: "Jaws Unleashed"
region: "PAL-M5"
@@ -27493,7 +27523,7 @@ SLES-54586:
name: "Ar tonelico - Melody of Elemia"
region: "PAL-E"
gsHWFixes:
roundSprite: 1 # Fixes vertical lines in FMVs, character portraits and other sprites.
roundSprite: 2 # Fixes vertical lines in FMVs, character portraits and other sprites.
SLES-54587:
name: "Raw Danger"
region: "PAL-E"
@@ -55077,6 +55107,9 @@ SLPS-20015:
name-en: "Tekken Tag Tournament"
region: "NTSC-J"
compat: 5
clampModes:
eeClampMode: 3 # Fixes camera and stops constant coin noises on Pirates Cove.
vu1ClampMode: 2 # Fixes broken effects.
gsHWFixes:
alignSprite: 1 # Fixes vertical lines.
SLPS-20016:
@@ -58185,6 +58218,9 @@ SLPS-25100:
name-en: "Tekken 4"
region: "NTSC-J"
compat: 5
clampModes:
eeClampMode: 3 # Fixes camera and stops constant coin noises on Pirates Cove.
vu1ClampMode: 2 # Fixes broken effects.
gsHWFixes:
alignSprite: 1 # Fixes vertical lines.
SLPS-25101:
@@ -61251,7 +61287,7 @@ SLPS-25604:
name-en: "Ar tonelico - Sekai no Owari de Utai Tsuzukeru Shoujo"
region: "NTSC-J"
gsHWFixes:
roundSprite: 1 # Fixes vertical lines in FMVs, character portraits and other sprites.
roundSprite: 2 # Fixes vertical lines in FMVs, character portraits and other sprites.
SLPS-25605:
name: "NEOGEOオンラインコレクション Vol.3 THE KING OF FIGHTERS ~オロチ編~ [通常版]"
name-sort: "ねおじおおんらいんこれくしょん Vol. 3 ざ きんぐ おぶ ふぁいたーず おろちへん [つうじょうばん]"
@@ -63672,6 +63708,9 @@ SLPS-71501:
name-sort: "てっけんたっぐとーなめんと [MEGA HITS!]"
name-en: "Tekken Tag Tournament [MEGA HITS!]"
region: "NTSC-J"
clampModes:
eeClampMode: 3 # Fixes camera and stops constant coin noises on Pirates Cove.
vu1ClampMode: 2 # Fixes broken effects.
gsHWFixes:
alignSprite: 1 # Fixes vertical lines.
SLPS-71502:
@@ -63768,6 +63807,9 @@ SLPS-73104:
name-sort: "てっけんたっぐとーなめんと [PlayStation2 the Best]"
name-en: "Tekken Tag Tournament [PlayStation2 the Best]"
region: "NTSC-J"
clampModes:
eeClampMode: 3 # Fixes camera and stops constant coin noises on Pirates Cove.
vu1ClampMode: 2 # Fixes broken effects.
gsHWFixes:
alignSprite: 1 # Fixes vertical lines.
SLPS-73105:
@@ -63904,6 +63946,9 @@ SLPS-73209:
name-sort: "てっけん4 [PlayStation2 the Best]"
name-en: "Tekken 4 [PlayStation2 the Best]"
region: "NTSC-J"
clampModes:
eeClampMode: 3 # Fixes camera and stops constant coin noises on Pirates Cove.
vu1ClampMode: 2 # Fixes broken effects.
gsHWFixes:
alignSprite: 1 # Fixes vertical lines.
SLPS-73210:
@@ -64296,7 +64341,7 @@ SLPS-73249:
name-en: "Ar tonelico - Sekai no Owari de Utai Tsuzukeru Shoujo [PlayStation2 the Best]"
region: "NTSC-J"
gsHWFixes:
roundSprite: 1 # Fixes vertical lines in FMVs, character portraits and other sprites.
roundSprite: 2 # Fixes vertical lines in FMVs, character portraits and other sprites.
SLPS-73250:
name: "エースコンバット・ゼロ・ザ・ベルカン・ウォー [PlayStation2 the Best]"
name-sort: "えーすこんばっと・ぜろ・ざ・べるかん・うぉー [PlayStation2 the Best]"
@@ -64673,6 +64718,9 @@ SLUS-20001:
name: "Tekken Tag Tournament"
region: "NTSC-U"
compat: 5
clampModes:
eeClampMode: 3 # Fixes camera and stops constant coin noises on Pirates Cove.
vu1ClampMode: 2 # Fixes broken effects.
gsHWFixes:
alignSprite: 1 # Fixes vertical lines.
SLUS-20002:
@@ -66078,6 +66126,9 @@ SLUS-20328:
name: "Tekken 4"
region: "NTSC-U"
compat: 5
clampModes:
eeClampMode: 3 # Fixes camera and stops constant coin noises on Pirates Cove.
vu1ClampMode: 2 # Fixes broken effects.
gsHWFixes:
alignSprite: 1 # Fixes vertical lines.
SLUS-20329:
@@ -69698,6 +69749,8 @@ SLUS-20983:
name: "Musashi Samurai Legend"
region: "NTSC-U"
compat: 5
gsHWFixes:
mergeSprite: 1 # Fixes ghosting.
SLUS-20984:
name: "Resident Evil Outbreak - File #2"
region: "NTSC-U"
@@ -71080,6 +71133,8 @@ SLUS-21204:
SLUS-21205:
name: "Aeon Flux"
region: "NTSC-U"
speedHacks:
eeCycleRate: 1 # Improves performance.
compat: 5
SLUS-21206:
name: "Shining Force Neo"
@@ -72811,7 +72866,7 @@ SLUS-21445:
region: "NTSC-U"
compat: 5
gsHWFixes:
roundSprite: 1 # Fixes vertical lines in FMVs, character portraits and other sprites.
roundSprite: 2 # Fixes vertical lines in FMVs, character portraits and other sprites.
SLUS-21446:
name: "Family Feud"
region: "NTSC-U"
@@ -75493,6 +75548,9 @@ SLUS-28010:
SLUS-28012:
name: "Tekken 4 [Trade Demo]"
region: "NTSC-U"
clampModes:
eeClampMode: 3 # Fixes camera and stops constant coin noises on Pirates Cove.
vu1ClampMode: 2 # Fixes broken effects.
gsHWFixes:
alignSprite: 1 # Fixes vertical lines.
SLUS-28013:
@@ -75787,6 +75845,9 @@ SLUS-29033:
SLUS-29034:
name: "Tekken 4 [Regular Demo]"
region: "NTSC-U"
clampModes:
eeClampMode: 3 # Fixes camera and stops constant coin noises on Pirates Cove.
vu1ClampMode: 2 # Fixes broken effects.
gsHWFixes:
alignSprite: 1 # Fixes vertical lines.
SLUS-29035:
@@ -222,6 +222,8 @@ val EN: Map<String, String> = mapOf(
"app.theme.system" to "System",
"app.theme.light" to "Light",
"app.theme.dark" to "Dark",
"app.theme.black" to "Black",
"app.theme.oled" to "OLED",
"app.bootLogo" to "Boot animation",
"app.bootLogo.desc" to "Play the ARMSX2 intro video when the app starts.",
"app.toolbarPosition" to "Library toolbar",
@@ -1043,6 +1045,7 @@ val EN: Map<String, String> = mapOf(
"setup.step.appData.title" to "App Data Folder",
"setup.step.bios.description" to "Pick a folder of PS2 BIOS files to start playing — every BIOS inside is added.",
"setup.step.bios.title" to "BIOS Location",
"bios.boot.title" to "Boot BIOS",
"setup.step.rom.description" to "Pick one or more folders where you keep your PS2 games. Supports ISO, CHD, BIN, IMG, MDF, and GZ.",
"setup.step.rom.title" to "ROM Location",
"setup.storageChooser.customFolder" to "Custom Folder…",
@@ -752,6 +752,13 @@ object ControllerMappings {
// consume (B = exit) — can be captured. Normal nav resumes when it clears.
val padCapturing = mutableStateOf(false)
/** A pad-button capture in progress from the Controls screen (ControllerManagerScreen). While
* set, MainActivityRuntime.dispatchKeyEvent binds the next pressed keycode via this lambda
* handled there, like [captureHotkey], instead of a focus-stealing AlertDialog whose separate
* window swallowed controller keys before Compose's onPreviewKeyEvent could see them (the
* 2.6.0 "can't remap buttons" bug). The lambda binds the key and returns true. */
val capturePadAction = mutableStateOf<((Int) -> Boolean)?>(null)
// Capture bridge: the Hotkeys tab calls [beginHotkeyCapture]; the next
// button(s) seen by MainActivityRuntime.dispatchKeyEvent are bound to it. Press one button
// for a single bind, or two together for a combo. Observed for UI feedback.
@@ -64,8 +64,11 @@ private val TrophyGold = Color(0xFFFFC93C)
private data class DrawerItem(
val titleKey: String,
val glyph: String,
val destination: AppRoute,
// A nav destination OR an action (onAction). Action rows (e.g. Boot BIOS) run onAction and
// close the drawer instead of navigating to a screen.
val destination: AppRoute? = null,
val iconRes: Int? = null,
val onAction: (() -> Unit)? = null,
)
@Composable
@@ -111,17 +114,20 @@ fun NavigationDrawer(
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.55f)),
shadowElevation = 18.dp,
) {
DrawerContent(selected, onNavigate)
DrawerContent(selected, onNavigate, onDismiss)
}
}
}
}
@Composable
private fun DrawerContent(selected: AppRoute, onNavigate: (AppRoute) -> Unit) {
private fun DrawerContent(selected: AppRoute, onNavigate: (AppRoute) -> Unit, onDismiss: () -> Unit) {
val isLandscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
val primary = listOf(
DrawerItem("games.section.library", "", AppRoute.Home),
// Boot straight into the PS2 system BIOS with no disc — distinct from "BIOS Location"
// below, which only points the emulator at your BIOS file.
DrawerItem("bios.boot.title", "", onAction = { MainActivityRuntime.startBios(); onDismiss() }),
DrawerItem("ra.title", "", AppRoute.Achievements, iconRes = com.armsx2.R.drawable.ic_trophy),
DrawerItem("action.settings", "", AppRoute.Settings()),
)
@@ -175,12 +181,12 @@ private fun DrawerSection(
)
items.forEachIndexed { index, item ->
DrawerRow(
controllerId = "drawer.${item.destination::class.simpleName}",
controllerId = "drawer.${item.destination?.let { it::class.simpleName } ?: item.titleKey}",
title = str(item.titleKey),
glyph = item.glyph,
iconRes = item.iconRes,
selected = sameDestination(selected, item.destination),
onClick = { onNavigate(item.destination) },
selected = item.destination != null && sameDestination(selected, item.destination),
onClick = { item.onAction?.invoke() ?: item.destination?.let(onNavigate) },
)
}
}
@@ -1515,7 +1515,9 @@ open class MainActivityRuntime : ComponentActivity() {
resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK !=
Configuration.UI_MODE_NIGHT_YES
com.armsx2.ui.theme.ThemeMode.Light -> true
com.armsx2.ui.theme.ThemeMode.Dark -> false
com.armsx2.ui.theme.ThemeMode.Dark,
com.armsx2.ui.theme.ThemeMode.Black,
com.armsx2.ui.theme.ThemeMode.Oled -> false
}
WindowInsetsControllerCompat(window, window.decorView).let { controller ->
controller.show(WindowInsetsCompat.Type.systemBars())
@@ -1585,7 +1587,9 @@ open class MainActivityRuntime : ComponentActivity() {
val darkTheme = when (com.armsx2.ui.theme.ThemePreferences.mode.value) {
com.armsx2.ui.theme.ThemeMode.System -> androidx.compose.foundation.isSystemInDarkTheme()
com.armsx2.ui.theme.ThemeMode.Light -> false
com.armsx2.ui.theme.ThemeMode.Dark -> true
com.armsx2.ui.theme.ThemeMode.Dark,
com.armsx2.ui.theme.ThemeMode.Black,
com.armsx2.ui.theme.ThemeMode.Oled -> true
}
androidx.compose.runtime.SideEffect {
applySystemBarTheme(darkTheme = darkTheme, showSystemBars = showSystemBars)
@@ -1901,6 +1905,18 @@ open class MainActivityRuntime : ComponentActivity() {
}
return true // swallow down + up while capturing
}
// Pad-button capture (Controls screen): bind here — like the hotkey capture above —
// instead of via Compose's onPreviewKeyEvent, because the bind prompt is no longer a
// focus-stealing dialog (a Dialog window would swallow controller keys before Compose or
// this handler saw them — the 2.6.0 "can't remap buttons" bug). Bind the first real key
// press; swallow down+up so nav (B = exit, A = confirm) can't fire mid-capture.
val padCapture = ControllerMappings.capturePadAction.value
if (padCapture != null) {
if (kc != KeyEvent.KEYCODE_UNKNOWN && event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
padCapture(kc)
}
return true
}
// Pad-button capture (Pad tab): let every key fall through to Compose's
// onPreviewKeyEvent so ANY button binds — without this the overlay nav
// below eats B (exit), A (confirm), Y, D-pad and L1/R1 before they reach
@@ -15,6 +15,11 @@ object InGameOverlay {
val hardcoreOn = mutableStateOf(false)
val frameLimitOn = mutableStateOf(true)
/** Per-tab scroll offset (px) of the in-game pause menu, retained across menu open/close so
* reopening a tab especially the long Fixes list returns to where you were instead of
* snapping back to the top. Keyed by EmulationMenuTab.name to avoid coupling to that enum. */
val menuTabScroll = HashMap<String, Int>()
fun saveSettings(updated: Settings) {
val previous = settingsState.value
settingsState.value = updated
@@ -18,7 +18,6 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
@@ -88,14 +87,11 @@ fun ControllerManagerScreen(onBack: () -> Unit, viewModel: ControllerManagerView
}
}
state.capturingAction?.let { action ->
AlertDialog(
onDismissRequest = viewModel::cancelCapture,
title = { Text("${str("pad.action.bind")}: ${action.label}") },
text = { Text(str("hotkeys.capturePrompt")) },
confirmButton = { TextButton(onClick = viewModel::cancelCapture) { Text(str("action.cancel")) } },
)
}
// No capture dialog. A Compose AlertDialog is a separate focused WINDOW that swallowed all
// controller key events before either this Activity's dispatchKeyEvent or the main Compose
// tree could see them — so no button ever bound (the 2.6.0 "can't remap buttons" bug). The
// bind prompt now shows inline on the row (highlight + "press a button" text, like hotkeys),
// and the key is captured in MainActivityRuntime.dispatchKeyEvent via capturePadAction.
}
@Composable
@@ -163,7 +159,7 @@ private fun ControllerBindings(state: ControllerManagerUiState, viewModel: Contr
label = action.label,
binding = ControllerMappings.labelForKey(ControllerMappings.physicalFor(action, state.player)),
capturing = state.capturingAction == action,
onBind = { viewModel.beginCapture(action) },
onBind = { if (state.capturingAction == action) viewModel.cancelCapture() else viewModel.beginCapture(action) },
onClear = { viewModel.clear(action) },
)
}
@@ -41,6 +41,9 @@ class ControllerManagerViewModel(application: Application) : AndroidViewModel(ap
fun beginCapture(action: ControllerMappings.Action) {
ControllerMappings.padCapturing.value = true
// Bind via the Activity's dispatchKeyEvent (see ControllerMappings.capturePadAction) so any
// button binds even though the bind prompt is no longer a focus-stealing dialog.
ControllerMappings.capturePadAction.value = { kc -> captureKey(kc) }
state.value = state.value.copy(capturingAction = action)
}
@@ -48,12 +51,14 @@ class ControllerManagerViewModel(application: Application) : AndroidViewModel(ap
val action = state.value.capturingAction ?: return false
ControllerMappings.bind(action, keyCode, state.value.player)
ControllerMappings.padCapturing.value = false
ControllerMappings.capturePadAction.value = null
state.value = state.value.copy(capturingAction = null, tick = state.value.tick + 1)
return true
}
fun cancelCapture() {
ControllerMappings.padCapturing.value = false
ControllerMappings.capturePadAction.value = null
state.value = state.value.copy(capturingAction = null)
}
@@ -205,7 +205,16 @@ private fun MenuPage(
modifier: Modifier,
) {
val tabScrollStates = remember {
EmulationMenuTab.entries.associateWith { ScrollState(initial = 0) }
EmulationMenuTab.entries.associateWith {
ScrollState(initial = InGameOverlay.menuTabScroll[it.name] ?: 0)
}
}
// Remember each tab's scroll offset when the menu closes so reopening a tab (especially
// the long Fixes list) returns to where you were instead of snapping back to the top.
androidx.compose.runtime.DisposableEffect(Unit) {
onDispose {
tabScrollStates.forEach { (tab, ss) -> InGameOverlay.menuTabScroll[tab.name] = ss.value }
}
}
val scrollState = tabScrollStates.getValue(state.tab)
// Provide the pane's scroll state to the settings widgets so the Fixes pane's
@@ -432,6 +441,12 @@ private fun MenuHeader(compact: Boolean, hardcore: Boolean, richPresence: String
Spacer(Modifier.width(8.dp))
HardcoreBadge()
}
// File-type chip after the HC badge (ISO / CHD / …), mirroring the library
// list view so the pause/RA header shows the same at-a-glance file info.
game?.let { g ->
Spacer(Modifier.width(6.dp))
com.armsx2.ui.common.StatusChip(g.extension.ifBlank { g.platform.key.uppercase() })
}
}
if (!game?.serial.isNullOrBlank()) {
Spacer(Modifier.height(2.dp))
@@ -187,7 +187,10 @@ class EmulationMenuViewModel(application: Application) : AndroidViewModel(applic
}
fun setUpscale(value: Float) {
val normalized = value.coerceIn(1f, 8f)
// Allow sub-native (0.25x0.75x, issue #207) — the in-game menu offers them via
// UPSCALE_OPTIONS, so don't clamp them up to Native like the old 1f floor did (that
// made every below-Native pick silently apply as Native). Matches the settings tab.
val normalized = value.coerceIn(0.25f, 8f)
updateSettings { it.copy(upscaleFloat = normalized) }
MainActivityRuntime.upscale.value = normalized
NativeApp.renderUpscalemultiplier(normalized)
@@ -73,17 +73,24 @@ fun AppTab() {
SegmentedRow(
label = str("app.theme"),
options = listOf(str("app.theme.system"), str("app.theme.light"), str("app.theme.dark")),
options = listOf(
str("app.theme.system"), str("app.theme.light"), str("app.theme.dark"),
str("app.theme.black"), str("app.theme.oled"),
),
selectedIndex = when (ThemePreferences.mode.value) {
ThemeMode.System -> 0
ThemeMode.Light -> 1
ThemeMode.Dark -> 2
ThemeMode.Black -> 3
ThemeMode.Oled -> 4
},
onChange = { index ->
ThemePreferences.set(
when (index) {
1 -> ThemeMode.Light
2 -> ThemeMode.Dark
3 -> ThemeMode.Black
4 -> ThemeMode.Oled
else -> ThemeMode.System
},
)
@@ -67,6 +67,13 @@ import com.armsx2.ui.settings.LocalSettingsScrollState
private data class SettingsSection(val category: SettingsCategory, val titleKey: String, val glyph: String)
/** Retains the settings page's scroll offset across close/reopen. The selected category already
* persists in the view-model, so restoring this one offset returns you to where you were (esp.
* useful in long lists like Fixes) instead of snapping to the top every time. */
private object SettingsScrollMemory {
var lastOffset = 0
}
@Composable
fun SettingsScreen(
initialCategory: SettingsCategory,
@@ -91,7 +98,10 @@ fun SettingsScreen(
pendingJump = label
}
}
val screenScroll = rememberScrollState()
val screenScroll = rememberScrollState(initial = SettingsScrollMemory.lastOffset)
androidx.compose.runtime.DisposableEffect(Unit) {
onDispose { SettingsScrollMemory.lastOffset = screenScroll.value }
}
LaunchedEffect(initialCategory, game?.uri) { viewModel.load(initialCategory, game) }
// When the controller focus returns to the category-chip row (the top-most
// navigable element), snap the whole page back to the top so the title bar +
@@ -10,7 +10,7 @@ import androidx.compose.ui.graphics.Color
import com.armsx2.runtime.MainActivityRuntime
import androidx.core.content.edit
enum class ThemeMode { System, Dark, Light }
enum class ThemeMode { System, Dark, Light, Black, Oled }
object ThemePreferences {
private const val PreferenceKey = "ui.theme.mode"
@@ -21,6 +21,8 @@ object ThemePreferences {
mode.value = when (MainActivityRuntime.prefs.getString(PreferenceKey, ThemeMode.System.name)) {
ThemeMode.System.name -> ThemeMode.System
ThemeMode.Light.name -> ThemeMode.Light
ThemeMode.Black.name -> ThemeMode.Black
ThemeMode.Oled.name -> ThemeMode.Oled
else -> ThemeMode.Dark
}
}
@@ -139,15 +141,43 @@ private val DayScheme = lightColorScheme(
surfaceTint = Color.Transparent,
)
// Neutral dark schemes derived from Night — same accents, but black/neutral-grey backgrounds and
// selection highlights instead of Night's blue-tinted ones (users asked for a black, not-blue UI).
// Black = a slightly-lifted neutral dark; Oled = true #000000 for OLED panels (deepest black, less
// power). Only surfaces/containers/outlines change; the primary accent stays for contrast/usability.
private val BlackScheme = NightScheme.copy(
background = Color(0xFF0B0B0B),
surface = Color(0xFF111111),
surfaceVariant = Color(0xFF1B1B1B),
primaryContainer = Color(0xFF242424),
onPrimaryContainer = Color(0xFFEAEAEA),
secondaryContainer = Color(0xFF1A1A1A),
outline = Color(0xFF333333),
outlineVariant = Color(0xFF242424),
)
private val OledScheme = NightScheme.copy(
background = Color.Black,
surface = Color.Black,
surfaceVariant = Color(0xFF0E0E0E),
primaryContainer = Color(0xFF1A1A1A),
onPrimaryContainer = Color(0xFFEAEAEA),
secondaryContainer = Color(0xFF121212),
outline = Color(0xFF262626),
outlineVariant = Color(0xFF161616),
)
@Composable
fun Armsx2Theme(content: @Composable () -> Unit) {
val dark = when (ThemePreferences.mode.value) {
ThemeMode.System -> isSystemInDarkTheme()
ThemeMode.Dark -> true
ThemeMode.Light -> false
val scheme = when (ThemePreferences.mode.value) {
ThemeMode.System -> if (isSystemInDarkTheme()) NightScheme else DayScheme
ThemeMode.Dark -> NightScheme
ThemeMode.Light -> DayScheme
ThemeMode.Black -> BlackScheme
ThemeMode.Oled -> OledScheme
}
MaterialTheme(
colorScheme = if (dark) NightScheme else DayScheme,
colorScheme = scheme,
typography = ArmsTypography,
content = content,
)
@@ -336,21 +336,35 @@ public class NativeApp {
// Uses MediaPlayer, not SoundPool: SoundPool decoded/resampled the 44.1 kHz
// stereo PCM oddly and it came out "weird". MediaPlayer plays the .wav straight,
// matching desktop PCSX2. One short-lived player per shot, released on complete.
// Strong references to the currently-playing sound players. Without this the
// MediaPlayer below is a pure local; once start() returns and the worker thread
// exits, nothing roots it, so the GC (very active under a running emulator) could
// finalize and release it MID-PLAYBACK that's why unlock sounds dropped at random
// with no error logged. Held from before start() until the completion/error callback.
private static final java.util.Set<android.media.MediaPlayer> sActiveSounds =
java.util.Collections.synchronizedSet(new java.util.HashSet<>());
public static void playSound(String path) {
if (path == null || path.isEmpty()) return;
// Cap concurrent players a burst of simultaneous unlocks (combo/milestone) could
// otherwise exhaust the device's MediaPlayer/codec pool and make start() no-op.
if (sActiveSounds.size() >= 4) return;
new Thread(() -> {
android.media.MediaPlayer mp = null;
try {
android.media.MediaPlayer mp = new android.media.MediaPlayer();
mp = new android.media.MediaPlayer();
mp.setAudioAttributes(new android.media.AudioAttributes.Builder()
.setUsage(android.media.AudioAttributes.USAGE_ASSISTANCE_SONIFICATION)
.setContentType(android.media.AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build());
mp.setDataSource(path);
mp.setOnCompletionListener(m -> { try { m.release(); } catch (Throwable ignore) {} });
mp.setOnErrorListener((m, what, extra) -> { try { m.release(); } catch (Throwable ignore) {} return true; });
mp.setOnCompletionListener(m -> { sActiveSounds.remove(m); try { m.release(); } catch (Throwable ignore) {} });
mp.setOnErrorListener((m, what, extra) -> { sActiveSounds.remove(m); try { m.release(); } catch (Throwable ignore) {} return true; });
sActiveSounds.add(mp);
mp.prepare();
mp.start();
} catch (Throwable t) {
if (mp != null) { sActiveSounds.remove(mp); try { mp.release(); } catch (Throwable ignore) {} }
android.util.Log.e("ARMSX2", "playSound failed: " + path, t);
}
}, "armsx2-ra-sound").start();