diff --git a/pcsx2/ImGui/ImGuiOverlays.cpp b/pcsx2/ImGui/ImGuiOverlays.cpp index fe83e8bdfa..5a07d13c6c 100644 --- a/pcsx2/ImGui/ImGuiOverlays.cpp +++ b/pcsx2/ImGui/ImGuiOverlays.cpp @@ -492,6 +492,25 @@ __ri void ImGuiManager::DrawPerformanceOverlay(float& position_y, float scale, f #if defined(__ANDROID__) if (const u32 skip = GSGetManualFrameSkip(); skip > 0) s_speed_line.append_format("{}SKIP: {}", s_speed_line.empty() ? "" : " | ", skip); + + // Device thermals, pushed in from the Android side (Cotcho: "temp sensor on + // applicable device as part of stats OSD"). The core cannot read them itself -- + // there is no portable API, and on Android the only route is a vendor-specific + // sysfs the app layer already discovers. So this draws what it was given and knows + // nothing about where it came from; a sensor that could not be read is simply + // absent rather than shown as a zero. + if (Armsx2Thermals::show.load(std::memory_order_relaxed)) + { + const float cpu_t = Armsx2Thermals::cpu.load(std::memory_order_relaxed); + const float gpu_t = Armsx2Thermals::gpu.load(std::memory_order_relaxed); + const float bat_t = Armsx2Thermals::battery.load(std::memory_order_relaxed); + if (cpu_t > ARMSX2_THERMAL_NONE) + s_speed_line.append_format("{}CPU {:.0f}\xc2\xb0", s_speed_line.empty() ? "" : " | ", cpu_t); + if (gpu_t > ARMSX2_THERMAL_NONE) + s_speed_line.append_format("{}GPU {:.0f}\xc2\xb0", s_speed_line.empty() ? "" : " | ", gpu_t); + if (bat_t > ARMSX2_THERMAL_NONE) + s_speed_line.append_format("{}BAT {:.0f}\xc2\xb0", s_speed_line.empty() ? "" : " | ", bat_t); + } #endif if (GSConfig.OsdShowFPS) @@ -2065,6 +2084,18 @@ void SaveStateSelectorUI::ShowSlotOSDMessage() } #ifdef __ANDROID__ +// Device temperatures, written by the Android app layer and read by the perf overlay above. +// Atomics because the writer is a UI-thread poll and the reader is the GS thread; relaxed +// because these are three independent display values with no ordering relationship to +// anything -- a torn read would at worst show one stale number for one frame. +namespace Armsx2Thermals +{ + std::atomic cpu{ARMSX2_THERMAL_NONE}; + std::atomic gpu{ARMSX2_THERMAL_NONE}; + std::atomic battery{ARMSX2_THERMAL_NONE}; + std::atomic show{false}; +} // namespace Armsx2Thermals + namespace { // Reload-immune snapshot of the Android UI's OSD choice. VMManager::ApplySettings // re-derives EmuConfig.GS from the layered settings interface (base + per-game) every diff --git a/pcsx2/ImGui/ImGuiOverlays.h b/pcsx2/ImGui/ImGuiOverlays.h index d0d3817f73..cdc8ed9d56 100644 --- a/pcsx2/ImGui/ImGuiOverlays.h +++ b/pcsx2/ImGui/ImGuiOverlays.h @@ -59,3 +59,25 @@ namespace InputRecordingUI } extern InputRecordingUI::InputRecordingData g_InputRecordingData; + +#ifdef __ANDROID__ +#include + +/// Sentinel for "this sensor could not be read". Below any real temperature, so a single +/// comparison distinguishes absent from cold without a second flag per value. +#define ARMSX2_THERMAL_NONE (-1000.0f) + +/// Device temperatures for the performance overlay. +/// +/// The core has no way to read these: there is no portable API, and on Android the only route +/// is a vendor-specific sysfs whose zone names and units differ per SoC. The app layer already +/// discovers all that for the second-screen panel, so it pushes the values in here and the +/// overlay just draws them. Written from a UI-thread poll, read on the GS thread. +namespace Armsx2Thermals +{ + extern std::atomic cpu; + extern std::atomic gpu; + extern std::atomic battery; + extern std::atomic show; +} // namespace Armsx2Thermals +#endif diff --git a/platforms/android/app/src/main/cpp/native-lib.cpp b/platforms/android/app/src/main/cpp/native-lib.cpp index d42f501059..c68830e3fe 100644 --- a/platforms/android/app/src/main/cpp/native-lib.cpp +++ b/platforms/android/app/src/main/cpp/native-lib.cpp @@ -848,6 +848,66 @@ Java_kr_co_iefriends_pcsx2_NativeApp_getNominalFrameRate(JNIEnv*, jclass) { return VMManager::HasValidVM() ? static_cast(VMManager::GetFrameRate()) : 0.0f; } +/* + * The rest of what the in-game OSD shows, for the second-screen panel. + * + * The panel could only reach getFPS(), so it could show frames and a percentage of nominal and + * nothing else -- "I would appreciate more info from the OSD available on the second screen" + * (Mike22). PerformanceMetrics already computes all of this for the OSD; none of it had a way + * across the JNI boundary. Each returns 0 with no VM rather than the last value, so a panel + * sitting in the library reads as idle instead of frozen on whatever the last game was doing. + */ +/* + * Hand the overlay the device temperatures the app layer read. See ImGuiOverlays.h for why the + * core cannot read them itself. Values use ARMSX2_THERMAL_NONE for "no reading", so a device + * that exposes no usable zone shows nothing rather than a plausible-looking zero. + */ +extern "C" +JNIEXPORT void JNICALL +Java_kr_co_iefriends_pcsx2_NativeApp_setThermals(JNIEnv*, jclass, jfloat cpu, jfloat gpu, + jfloat battery, jboolean show) { + Armsx2Thermals::cpu.store(cpu, std::memory_order_relaxed); + Armsx2Thermals::gpu.store(gpu, std::memory_order_relaxed); + Armsx2Thermals::battery.store(battery, std::memory_order_relaxed); + Armsx2Thermals::show.store(show == JNI_TRUE, std::memory_order_relaxed); +} + +extern "C" +JNIEXPORT jfloat JNICALL +Java_kr_co_iefriends_pcsx2_NativeApp_getVPS(JNIEnv*, jclass) { + return VMManager::HasValidVM() ? static_cast(PerformanceMetrics::GetInternalFPS()) : 0.0f; +} + +extern "C" +JNIEXPORT jfloat JNICALL +Java_kr_co_iefriends_pcsx2_NativeApp_getEmuSpeedPercent(JNIEnv*, jclass) { + return VMManager::HasValidVM() ? static_cast(PerformanceMetrics::GetSpeed()) : 0.0f; +} + +extern "C" +JNIEXPORT jfloat JNICALL +Java_kr_co_iefriends_pcsx2_NativeApp_getCpuThreadUsage(JNIEnv*, jclass) { + return VMManager::HasValidVM() ? static_cast(PerformanceMetrics::GetCPUThreadUsage()) : 0.0f; +} + +extern "C" +JNIEXPORT jfloat JNICALL +Java_kr_co_iefriends_pcsx2_NativeApp_getGsThreadUsage(JNIEnv*, jclass) { + return VMManager::HasValidVM() ? static_cast(PerformanceMetrics::GetGSThreadUsage()) : 0.0f; +} + +extern "C" +JNIEXPORT jfloat JNICALL +Java_kr_co_iefriends_pcsx2_NativeApp_getGpuUsage(JNIEnv*, jclass) { + return VMManager::HasValidVM() ? static_cast(PerformanceMetrics::GetGPUUsage()) : 0.0f; +} + +extern "C" +JNIEXPORT jfloat JNICALL +Java_kr_co_iefriends_pcsx2_NativeApp_getAverageFrameTime(JNIEnv*, jclass) { + return VMManager::HasValidVM() ? static_cast(PerformanceMetrics::GetAverageFrameTime()) : 0.0f; +} + extern "C" JNIEXPORT jstring JNICALL Java_kr_co_iefriends_pcsx2_NativeApp_getPauseGameTitle(JNIEnv *env, jclass clazz) { diff --git a/platforms/android/app/src/main/java/com/armsx2/SecondScreen.kt b/platforms/android/app/src/main/java/com/armsx2/SecondScreen.kt index a9a44c45ac..08fbde22b0 100644 --- a/platforms/android/app/src/main/java/com/armsx2/SecondScreen.kt +++ b/platforms/android/app/src/main/java/com/armsx2/SecondScreen.kt @@ -79,12 +79,57 @@ object SecondScreen { const val BG_LIBRARY = 1 const val BG_BLACK = 2 + /** A user-supplied image, the fourth choice ("or perhaps an own background"). */ + const val BG_CUSTOM = 3 + private const val PREF_BACKGROUND_URI = "secondScreen.background.uri" + val background = mutableStateOf(BG_THEME) + val backgroundUri = mutableStateOf(null) + + /** + * Adopt a picked image. Takes the persistable read grant, exactly as the library's own + * background picker does -- without it the URI works until the process restarts and then + * silently resolves to nothing, which reads as "my background disappeared". + */ + fun setBackgroundImage(context: Context, uri: android.net.Uri) { + runCatching { + context.contentResolver.takePersistableUriPermission( + uri, android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION, + ) + } + backgroundUri.value = uri.toString() + runCatching { + MainActivityRuntime.prefs.edit().putString(PREF_BACKGROUND_URI, uri.toString()).apply() + } + setBackground(BG_CUSTOM) + } + + // ---- Top bar ------------------------------------------------------------------------------ + /** Clock and battery in a slim bar across the top rather than as grid tiles ("much cleaner to + * just move the time and battery into a top bar similar to the ayn thors menu"). On by + * default: it is the same information, and it gives the grid back two cells. */ + private const val PREF_TOP_BAR = "secondScreen.topBar" + val topBar = mutableStateOf(true) + + fun loadTopBar() { + topBar.value = runCatching { + MainActivityRuntime.prefs.getBoolean(PREF_TOP_BAR, true) + }.getOrDefault(true) + } + + fun setTopBar(on: Boolean) { + topBar.value = on + runCatching { MainActivityRuntime.prefs.edit().putBoolean(PREF_TOP_BAR, on).apply() } + rebuild() + } fun loadBackground() { background.value = runCatching { MainActivityRuntime.prefs.getInt(PREF_BACKGROUND, BG_THEME) - }.getOrDefault(BG_THEME).coerceIn(BG_THEME, BG_BLACK) + }.getOrDefault(BG_THEME).coerceIn(BG_THEME, BG_CUSTOM) + backgroundUri.value = runCatching { + MainActivityRuntime.prefs.getString(PREF_BACKGROUND_URI, null) + }.getOrNull() } // ---- Thermal polling interval ----------------------------------------------------------- @@ -109,7 +154,7 @@ object SecondScreen { private fun tempIntervalMs(): Long = tempIntervalSec.value * 1000L fun setBackground(value: Int) { - background.value = value.coerceIn(BG_THEME, BG_BLACK) + background.value = value.coerceIn(BG_THEME, BG_CUSTOM) runCatching { MainActivityRuntime.prefs.edit().putInt(PREF_BACKGROUND, background.value).apply() } rebuild() } @@ -160,6 +205,8 @@ object SecondScreen { } loadBackground() loadTempInterval() + loadTopBar() + loadIgnoredDisplays() } fun set(context: Context, value: Boolean) { @@ -255,9 +302,56 @@ object SecondScreen { }.getOrNull() ?: Display.DEFAULT_DISPLAY } + // ---- Per-display opt-out ------------------------------------------------------------------ + /** + * Displays the user has told the panel to stay off. + * + * "The second screen also still appears on the external monitor when connected via usbc" + * (NiceRon). By the display-picking rule a USB-C monitor is a perfectly good second display, + * so this is not a bug to fix but a preference to record -- someone with a dual-screen + * handheld wants the panel on the bottom screen and NOT on the TV they occasionally plug in, + * and no rule about internal-vs-external gets that right for everyone. Android does not + * expose a stable public display type before API 34 either, so guessing would be wrong on + * old devices as well as on unusual ones. + * + * Keyed by NAME rather than displayId: ids are reassigned across replugs, names are not. + */ + private const val PREF_IGNORED = "secondScreen.ignoredDisplays" + val ignoredDisplays = mutableStateOf>(emptySet()) + + fun loadIgnoredDisplays() { + ignoredDisplays.value = runCatching { + MainActivityRuntime.prefs.getStringSet(PREF_IGNORED, emptySet())?.toSet() + }.getOrNull() ?: emptySet() + } + + private fun persistIgnored() { + runCatching { + MainActivityRuntime.prefs.edit().putStringSet(PREF_IGNORED, ignoredDisplays.value).apply() + } + } + + /** Stop using [name] and take the panel down from it now. */ + fun ignoreDisplay(name: String) { + if (name.isBlank()) return + ignoredDisplays.value = ignoredDisplays.value + name + persistIgnored() + detach() + MainActivityRuntime.instance?.let { refresh(it.applicationContext) } + } + + /** Forget every opt-out, so the panel can use any second display again. */ + fun clearIgnoredDisplays() { + ignoredDisplays.value = emptySet() + persistIgnored() + MainActivityRuntime.instance?.let { refresh(it.applicationContext) } + } + private fun secondaryDisplay(context: Context): Display? { val dm = context.getSystemService(Context.DISPLAY_SERVICE) as? DisplayManager ?: return null val hostId = hostDisplayId(context) + val ignored = ignoredDisplays.value + fun usable(d: Display) = d.displayId != hostId && d.name !in ignored // PRESENTATION category is the one Android intends for this; fall back to "any display the // app itself isn't on" because some handhelds don't tag their second panel. Both paths // exclude the host — a display can be PRESENTATION-tagged and still be the one showing the @@ -265,9 +359,9 @@ object SecondScreen { val presentationDisplays = runCatching { dm.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION) }.getOrNull() - presentationDisplays?.firstOrNull { it.displayId != hostId }?.let { return it } + presentationDisplays?.firstOrNull { usable(it) }?.let { return it } return runCatching { - dm.displays?.firstOrNull { it.displayId != hostId } + dm.displays?.firstOrNull { usable(it) } }.getOrNull() } @@ -292,6 +386,9 @@ object SecondScreen { private lateinit var stats: TextView private lateinit var idleLabel: TextView private lateinit var grid: android.widget.GridLayout + /** Null when the top bar is off; the clock and battery are grid tiles then instead. */ + private var topBarClock: TextView? = null + private var topBarBattery: TextView? = null private var dp: Float = 1f private val tileViews = HashMap() /** Rows that only make sense with a game running; hidden in the library. */ @@ -329,6 +426,40 @@ object SecondScreen { setPadding(pad, pad, pad, pad) } + // ---- Top bar: clock left, battery right ------------------------------------------- + // A status strip rather than two grid cells. Same information, but it stops the + // clock competing for space with the things you actually press, and it gives the + // panel the shape of a menu instead of a wall of identical boxes. + if (topBar.value) { + val bar = LinearLayout(context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + setPadding(0, 0, 0, (dp * 10).toInt()) + } + topBarClock = TextView(context).apply { + setTextColor(TEXT) + textSize = 15f + gravity = Gravity.START + } + topBarBattery = TextView(context).apply { + setTextColor(TEXT_DIM) + textSize = 15f + gravity = Gravity.END + } + bar.addView(topBarClock, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) + bar.addView(topBarBattery, LinearLayout.LayoutParams(0, ViewGroup.LayoutParams.WRAP_CONTENT, 1f)) + rootView.addView(bar, lp()) + // A hairline under it, so the bar reads as chrome and not as another tile. + rootView.addView( + View(context).apply { setBackgroundColor(BORDER) }, + LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, (dp * 1).toInt()) + .apply { bottomMargin = (dp * 10).toInt() }, + ) + } else { + topBarClock = null + topBarBattery = null + } + stats = TextView(context).apply { setTextColor(TEXT_DIM) textSize = 13f @@ -355,10 +486,17 @@ object SecondScreen { useDefaultMargins = false } SecondScreenLayout.tiles().forEach { tile -> + // The bar owns these two while it is on; leaving them in the grid as well would + // show the time twice. + if (topBar.value && (tile == SecondScreenTile.CLOCK || tile == SecondScreenTile.BATTERY)) + return@forEach val view = buildTile(tile) ?: return@forEach + val fixedH = SecondScreenLayout.tileHeight() val params = android.widget.GridLayout.LayoutParams().apply { width = 0 - height = ViewGroup.LayoutParams.WRAP_CONTENT + // 0 means "as tall as the text needs", which is what the panel always did. + height = if (fixedH > 0) (dp * fixedH).toInt() + else ViewGroup.LayoutParams.WRAP_CONTENT columnSpec = android.widget.GridLayout.spec( android.widget.GridLayout.UNDEFINED, 1, 1f, ) @@ -437,6 +575,17 @@ object SecondScreen { private fun panelBackground(context: Context): android.graphics.drawable.Drawable = when (background.value) { BG_BLACK -> android.graphics.drawable.ColorDrawable(Color.BLACK) + BG_CUSTOM -> runCatching { + val uri = android.net.Uri.parse(backgroundUri.value ?: error("no image")) + val art = context.contentResolver.openInputStream(uri).use { stream -> + android.graphics.drawable.Drawable.createFromStream(stream, uri.toString()) + } ?: error("undecodable") + // Same scrim as the library backdrop: an arbitrary photo has no contract to + // be dark, and tile text has to stay readable over whatever was picked. + android.graphics.drawable.LayerDrawable( + arrayOf(art, android.graphics.drawable.ColorDrawable(0xB0000000.toInt())), + ) + }.getOrElse { themeGround() } BG_LIBRARY -> runCatching { val art = androidx.core.content.ContextCompat.getDrawable( context, com.armsx2.R.drawable.library_bg_xmb, @@ -496,6 +645,8 @@ object SecondScreen { MainActivityRuntime.userHeldPause.value = true MainActivityRuntime.pause() } + // The panel knows its own display; the settings screen does not. + SecondScreenTile.NOT_HERE -> ignoreDisplay(display?.name.orEmpty()) SecondScreenTile.SCREENSHOT -> MainActivityRuntime.instance?.applicationContext?.let { Screenshots.capture(it) } SecondScreenTile.ASPECT -> { @@ -596,6 +747,15 @@ object SecondScreen { val clock = java.text.SimpleDateFormat("HH:mm", java.util.Locale.getDefault()) .format(java.util.Date(System.currentTimeMillis())) + // The bar, when it is the one showing these. Temps ride along on the right when the + // user has them, because that is where a status strip is read from. + topBarClock?.text = clock + topBarBattery?.text = buildString { + val t = Thermals.format(Thermals.cpu) + if (t != null) append("CPU $t ") + if (battery >= 0) append(batteryIcon(battery, charging)).append(" ").append(battery).append("%") + } + tileViews.forEach { (tile, view) -> val text: CharSequence? = when (tile) { SecondScreenTile.TITLE -> title.ifBlank { I18n.get("secondScreen.tile.title") } @@ -611,6 +771,23 @@ object SecondScreen { SecondScreenTile.BATTERY -> if (battery >= 0) batteryIcon(battery, charging) + "\n" + battery + "%" else null SecondScreenTile.CLOCK -> clock + SecondScreenTile.VPS -> + if (inGame) "VPS\n" + runCatching { NativeApp.getVPS() }.getOrDefault(0f).toInt() + else "VPS\n—" + SecondScreenTile.CPU_LOAD -> + if (inGame) "EE\n" + runCatching { NativeApp.getCpuThreadUsage() }.getOrDefault(0f).toInt() + "%" + else "EE\n—" + SecondScreenTile.GS_LOAD -> + if (inGame) "GS\n" + runCatching { NativeApp.getGsThreadUsage() }.getOrDefault(0f).toInt() + "%" + else "GS\n—" + SecondScreenTile.GPU_LOAD -> + if (inGame) "GPU\n" + runCatching { NativeApp.getGpuUsage() }.getOrDefault(0f).toInt() + "%" + else "GPU\n—" + SecondScreenTile.FRAME_TIME -> + if (inGame) "FRAME\n" + String.format( + java.util.Locale.US, "%.1f", runCatching { NativeApp.getAverageFrameTime() }.getOrDefault(0f), + ) + "ms" + else "FRAME\n—" SecondScreenTile.CPU_TEMP -> "CPU\n" + (Thermals.format(Thermals.cpu) ?: "—") SecondScreenTile.GPU_TEMP -> "GPU\n" + (Thermals.format(Thermals.gpu) ?: "—") SecondScreenTile.BATTERY_TEMP -> "BATT\n" + (Thermals.format(Thermals.battery) ?: "—") diff --git a/platforms/android/app/src/main/java/com/armsx2/SecondScreenTiles.kt b/platforms/android/app/src/main/java/com/armsx2/SecondScreenTiles.kt index 4b139c8e90..fe486ff6d8 100644 --- a/platforms/android/app/src/main/java/com/armsx2/SecondScreenTiles.kt +++ b/platforms/android/app/src/main/java/com/armsx2/SecondScreenTiles.kt @@ -39,6 +39,13 @@ enum class SecondScreenTile(val id: String, val labelKey: String, val stat: Bool // Thermals (Cotcho, Mike22). Stat tiles like the rest -- a device with no readable zone // simply shows a dash rather than the tile being hidden, so the grid does not reflow // depending on what the kernel happens to expose. + // The rest of what the in-game OSD shows (Mike22). Backed by new JNI getters -- until those + // existed, FPS was the only figure the panel could reach. + VPS("vps", "secondScreen.tile.vps", stat = true), + CPU_LOAD("cpuload", "secondScreen.tile.cpuLoad", stat = true), + GS_LOAD("gsload", "secondScreen.tile.gsLoad", stat = true), + GPU_LOAD("gpuload", "secondScreen.tile.gpuLoad", stat = true), + FRAME_TIME("frametime", "secondScreen.tile.frameTime", stat = true), CPU_TEMP("cputemp", "secondScreen.tile.cpuTemp", stat = true), GPU_TEMP("gputemp", "secondScreen.tile.gpuTemp", stat = true), BATTERY_TEMP("battemp", "secondScreen.tile.batteryTemp", stat = true), @@ -55,6 +62,9 @@ enum class SecondScreenTile(val id: String, val labelKey: String, val stat: Bool // was running on, with no way to dismiss it from there (BrainBeat: "I wonder if there is a way // to toggle it on inside the panel"). Turns the whole feature off, same as the App setting. HIDE("hide", "secondScreen.tile.hide", icon = "✕"), + // "Not on THIS screen" as distinct from "off entirely" — the panel is the only place that + // knows which display it landed on, so the opt-out belongs on it. + NOT_HERE("nothere", "secondScreen.tile.notHere", icon = "⤫"), MACRO1("macro1", "secondScreen.tile.macro1", icon = "①"), MACRO2("macro2", "secondScreen.tile.macro2", icon = "②"), @@ -87,14 +97,34 @@ object SecondScreenLayout { @Volatile private var tiles: List = DEFAULT @Volatile private var columnCount: Int = 3 + /** + * Tile height in dp, or 0 for "as tall as the text needs". + * + * Asked for as "be able to size the tiles by myself based on a fixed max height/width" + * (NiceRon). Width is already the column count -- tiles share the row equally, so choosing + * columns IS choosing width, and a second width control would just be a way to disagree with + * it. Height had no control at all, which is why a panel could only ever be as tall as its + * text; this is the missing half. + */ + private const val PREF_TILE_HEIGHT = "secondScreen.tileHeight" + @Volatile private var tileHeightDp: Int = 0 + fun tiles(): List = tiles fun columns(): Int = columnCount + fun tileHeight(): Int = tileHeightDp + + fun setTileHeight(dp: Int) { + tileHeightDp = dp.coerceIn(0, 200) + runCatching { MainActivityRuntime.prefs.edit().putInt(PREF_TILE_HEIGHT, tileHeightDp).apply() } + generation.intValue++ + } fun load() { runCatching { val raw = MainActivityRuntime.prefs.getString(PREF_TILES, null) tiles = if (raw == null) DEFAULT else parse(raw) columnCount = MainActivityRuntime.prefs.getInt(PREF_COLUMNS, 3).coerceIn(1, 6) + tileHeightDp = MainActivityRuntime.prefs.getInt(PREF_TILE_HEIGHT, 0).coerceIn(0, 200) } } @@ -125,6 +155,7 @@ object SecondScreenLayout { fun reset() { tiles = DEFAULT columnCount = 3 + tileHeightDp = 0 runCatching { MainActivityRuntime.prefs.edit().remove(PREF_TILES).remove(PREF_COLUMNS).apply() } diff --git a/platforms/android/app/src/main/java/com/armsx2/Thermals.kt b/platforms/android/app/src/main/java/com/armsx2/Thermals.kt index 233db57637..b7fe791fd6 100644 --- a/platforms/android/app/src/main/java/com/armsx2/Thermals.kt +++ b/platforms/android/app/src/main/java/com/armsx2/Thermals.kt @@ -111,6 +111,58 @@ object Thermals { }.getOrDefault(NONE) } + // ---- Feeding the in-game overlay ----------------------------------------------------- + // The panel polls on its own tick, but the overlay runs whether or not a second screen + // exists, so it needs a poll of its own. Same interval, same readings; the only extra cost + // is the JNI push, and it stops entirely when the option is off. + private const val PREF_OSD = "osd.showTemps" + private val handler = android.os.Handler(android.os.Looper.getMainLooper()) + private var feeding = false + + val osdEnabled = androidx.compose.runtime.mutableStateOf(false) + + fun loadOsdEnabled(context: Context) { + osdEnabled.value = runCatching { + com.armsx2.runtime.MainActivityRuntime.prefs.getBoolean(PREF_OSD, false) + }.getOrDefault(false) + applyOsd(context) + } + + fun setOsdEnabled(context: Context, on: Boolean) { + osdEnabled.value = on + runCatching { + com.armsx2.runtime.MainActivityRuntime.prefs.edit().putBoolean(PREF_OSD, on).apply() + } + applyOsd(context) + } + + private fun applyOsd(context: Context) { + if (osdEnabled.value) start(context) else stop() + } + + private fun start(context: Context) { + if (feeding) return + feeding = true + val app = context.applicationContext + val pump = object : Runnable { + override fun run() { + if (!feeding) return + val interval = com.armsx2.SecondScreen.tempIntervalSec.value * 1000L + poll(app, interval) + runCatching { kr.co.iefriends.pcsx2.NativeApp.setThermals(cpu, gpu, battery, true) } + handler.postDelayed(this, interval) + } + } + handler.post(pump) + } + + private fun stop() { + feeding = false + handler.removeCallbacksAndMessages(null) + // Tell the overlay to stop drawing them, rather than leaving the last values frozen there. + runCatching { kr.co.iefriends.pcsx2.NativeApp.setThermals(NONE, NONE, NONE, false) } + } + /** "48°" or null when there is no reading. */ fun format(c: Float): String? = if (c == NONE) null else "${c.toInt()}°" } diff --git a/platforms/android/app/src/main/java/com/armsx2/i18n/I18n.kt b/platforms/android/app/src/main/java/com/armsx2/i18n/I18n.kt index 9ed3f503f4..cbc00fecec 100644 --- a/platforms/android/app/src/main/java/com/armsx2/i18n/I18n.kt +++ b/platforms/android/app/src/main/java/com/armsx2/i18n/I18n.kt @@ -1703,10 +1703,23 @@ private val BASE_EN: Map = mapOf( "touch.editor.scopeGlobal" to "Editing Global Default touch layout", "touch.editor.show" to "Show", "touch.editor.tapHoldOff" to "Tap-Hold Off", + "secondScreen.tile.vps" to "VPS", + "secondScreen.tile.cpuLoad" to "EE load", + "secondScreen.tile.gsLoad" to "GS load", + "secondScreen.tile.gpuLoad" to "GPU load", + "secondScreen.tile.frameTime" to "Frame time", "secondScreen.tile.cpuTemp" to "CPU temp", "secondScreen.tile.gpuTemp" to "GPU temp", "secondScreen.tile.batteryTemp" to "Battery temp", "secondScreen.tempInterval" to "Sensor refresh", + "osd.temps" to "Device temperatures", + "secondScreen.layout.tileHeight" to "Tile height (0 = fit text)", + "secondScreen.tile.notHere" to "Not this screen", + "secondScreen.displays.reset" to "Re-enable skipped displays", + "secondScreen.topBar" to "Status bar", + "secondScreen.topBar.desc" to "Show the clock and battery in a bar across the top instead of as tiles", + "secondScreen.background.custom" to "Custom", + "secondScreen.background.choose" to "Choose panel image", "secondScreen.background" to "Panel background", "secondScreen.background.theme" to "Theme", "secondScreen.background.library" to "Library", diff --git a/platforms/android/app/src/main/java/com/armsx2/runtime/MainActivityRuntime.kt b/platforms/android/app/src/main/java/com/armsx2/runtime/MainActivityRuntime.kt index faa338c15e..abc86f2492 100644 --- a/platforms/android/app/src/main/java/com/armsx2/runtime/MainActivityRuntime.kt +++ b/platforms/android/app/src/main/java/com/armsx2/runtime/MainActivityRuntime.kt @@ -2235,6 +2235,7 @@ open class MainActivityRuntime : ComponentActivity() { com.armsx2.CoverRegionIndex.ensureBuilt(applicationContext) // Second-display utility panel (Ayn Thor / Retroid dual screen). No-op with one display. com.armsx2.SecondScreen.load() + runCatching { com.armsx2.Thermals.loadOsdEnabled(applicationContext) } com.armsx2.SecondScreenLayout.load() com.armsx2.SecondScreen.attach(applicationContext) com.armsx2.BatteryWatcher.load() diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/settings/AppTab.kt b/platforms/android/app/src/main/java/com/armsx2/ui/settings/AppTab.kt index 93db9af489..3108d8a4f7 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/settings/AppTab.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/settings/AppTab.kt @@ -475,16 +475,68 @@ fun AppTab() { // The panel now takes its colours from whichever theme is selected, so this is only // about the GROUND behind the tiles: the theme's own, the library's backdrop for // continuity with the screen it sits beside, or black for an OLED second display. + if (com.armsx2.SecondScreen.ignoredDisplays.value.isNotEmpty()) { + Row( + Modifier.fillMaxWidth().padding(top = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + val clear = { com.armsx2.SecondScreen.clearIgnoredDisplays() } + OutlinedButton( + onClick = clear, + modifier = Modifier.controllerFocusable("secondScreen.displays.reset", onConfirm = clear), + ) { + Text( + str("secondScreen.displays.reset") + + " (" + com.armsx2.SecondScreen.ignoredDisplays.value.size + ")", + ) + } + } + } + + ToggleRow( + label = str("secondScreen.topBar"), + value = com.armsx2.SecondScreen.topBar.value, + description = str("secondScreen.topBar.desc"), + onChange = { com.armsx2.SecondScreen.setTopBar(it) }, + ) + + val panelBgPicker = rememberLauncherForActivityResult( + ActivityResultContracts.OpenDocument() + ) { picked -> picked?.let { com.armsx2.SecondScreen.setBackgroundImage(appContext, it) } } + SegmentedRow( label = str("secondScreen.background"), options = listOf( str("secondScreen.background.theme"), str("secondScreen.background.library"), str("secondScreen.background.black"), + str("secondScreen.background.custom"), ), selectedIndex = com.armsx2.SecondScreen.background.value, - onChange = { com.armsx2.SecondScreen.setBackground(it) }, + onChange = { + // Picking "Custom" with nothing chosen yet opens the picker rather than + // selecting a mode that would render as the theme ground and look broken. + if (it == com.armsx2.SecondScreen.BG_CUSTOM && + com.armsx2.SecondScreen.backgroundUri.value == null + ) { + panelBgPicker.launch(arrayOf("image/*")) + } else { + com.armsx2.SecondScreen.setBackground(it) + } + }, ) + if (com.armsx2.SecondScreen.background.value == com.armsx2.SecondScreen.BG_CUSTOM) { + Row( + Modifier.fillMaxWidth().padding(top = 4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + val pick = { panelBgPicker.launch(arrayOf("image/*")) } + OutlinedButton( + onClick = pick, + modifier = Modifier.controllerFocusable("secondScreen.background.choose", onConfirm = pick), + ) { Text(str("secondScreen.background.choose")) } + } + } // Only worth showing once a thermal tile is actually on the panel — otherwise it is // a control over something invisible. @@ -596,6 +648,18 @@ fun AppTab() { com.armsx2.SecondScreen.rebuild() }, ) + // Columns already decide width (tiles split the row equally), so this is the other + // axis. 0 keeps the old behaviour of being exactly as tall as the text. + IntSliderRow( + label = str("secondScreen.layout.tileHeight"), + value = com.armsx2.SecondScreenLayout.tileHeight(), + min = 0, + max = 160, + onChange = { + com.armsx2.SecondScreenLayout.setTileHeight(it) + com.armsx2.SecondScreen.rebuild() + }, + ) val resetLayout = { com.armsx2.SecondScreenLayout.reset() com.armsx2.SecondScreen.rebuild() diff --git a/platforms/android/app/src/main/java/com/armsx2/ui/settings/OverlayTab.kt b/platforms/android/app/src/main/java/com/armsx2/ui/settings/OverlayTab.kt index edf43a9870..f566dd53fa 100644 --- a/platforms/android/app/src/main/java/com/armsx2/ui/settings/OverlayTab.kt +++ b/platforms/android/app/src/main/java/com/armsx2/ui/settings/OverlayTab.kt @@ -167,6 +167,15 @@ fun OverlayTab(state: MutableState) { } SettingsDivider() ToggleRow(str("overlay.toggle.cpuUsage"), s.osdShowCpu) { apply(s.copy(osdShowCpu = it)) } + // Device temperatures (Cotcho). Not a core setting like the rows around it: the core has + // no way to read a temperature, so the app polls and pushes the values in. Off by + // default — it is a sysfs read on a timer. + run { + val ctx = androidx.compose.ui.platform.LocalContext.current + ToggleRow(str("osd.temps"), com.armsx2.Thermals.osdEnabled.value) { + com.armsx2.Thermals.setOsdEnabled(ctx, it) + } + } SettingsDivider() ToggleRow(str("overlay.toggle.fps"), s.osdShowFps) { apply(s.copy(osdShowFps = it)) } SettingsDivider() diff --git a/platforms/android/app/src/main/java/kr/co/iefriends/pcsx2/NativeApp.java b/platforms/android/app/src/main/java/kr/co/iefriends/pcsx2/NativeApp.java index 1af6ccb389..b1a28caea6 100644 --- a/platforms/android/app/src/main/java/kr/co/iefriends/pcsx2/NativeApp.java +++ b/platforms/android/app/src/main/java/kr/co/iefriends/pcsx2/NativeApp.java @@ -228,6 +228,19 @@ public class NativeApp { /** Current game's nominal emulated refresh (~59.94 NTSC / 50 PAL), or 0 without a VM. */ public static native float getNominalFrameRate(); + /** The rest of the in-game OSD's figures, for the second-screen panel. All return 0 with + * no VM running rather than the last value, so an idle panel reads as idle. */ + /** Push device temperatures to the performance overlay. ARMSX2_THERMAL_NONE means + * "no reading" — the overlay then omits that figure rather than drawing a zero. */ + public static native void setThermals(float cpu, float gpu, float battery, boolean show); + + public static native float getVPS(); + public static native float getEmuSpeedPercent(); + public static native float getCpuThreadUsage(); + public static native float getGsThreadUsage(); + public static native float getGpuUsage(); + public static native float getAverageFrameTime(); + /** Build version string from BuildVersion::GitRev — formatted as * "GitTagHi.GitTagMid.GitTagLo.ARMSX2Build-SNAPSHOT". Used by the * setup wizard + in-game overlay branding so the displayed version