mirror of
https://github.com/ARMSX2/ARMSX2.git
synced 2026-08-24 16:50:16 -07:00
Second screen: follow the app's theme, tile icons, thermals, backgrounds
The panel carried a hand-written palette of neutral greys, on the reasoning that a Presentation sits outside the Compose tree and reading MaterialTheme from a plain View would mean holding a composition alive for six colours. The reasoning was right and the conclusion was not: ARMSX2's night theme is BLUE, so grey was not a neutral choice, it was a different app on the second screen. That is what "still looks quite unpleasant... more like stock android instead of armsx2" was describing. Armsx2Theme now publishes the RESOLVED scheme for code that cannot be a composable, and the panel reads that. No composition is held and there is no second copy of the theme logic to drift, so the panel follows Blue, Purple, OLED, Custom, Material You and the animated RGB mode without knowing any of them exist. Action tiles get a glyph over the label, because a tile has to be recognised from across a desk. Geometric Unicode rather than emoji: emoji bring their own colours and their own house style, which is the stock-Android look this is moving away from, while a glyph takes the accent like everything else. The two tiles that carry state SWAP their glyph rather than appending a line -- Pause shows what the tap will do, and Fast Forward no longer grows when you use it, which was half of the ragged-row problem. Panel background is now a choice: the theme's own ground, the library's backdrop darkened so it reads as the same app as the screen beside it, or solid black for an OLED second display. CPU, GPU and battery temperature tiles, asked for by two people. Android has no supported API -- HardwarePropertiesManager is signature-gated -- so this reads the thermal sysfs, which is permissionless but is not a contract: zone count, naming and even the UNIT are vendor-specific. Zones are discovered once by name, the unit is inferred by magnitude (no phone runs at 1000C and none idles at 0.045C, so the ranges cannot overlap), implausible values are dropped rather than displayed, and a device that exposes nothing shows a dash instead of a wrong number. Polling is on its own interval, 1 to 5 seconds -- that interval is the mitigation asked about, and it is why the panel tick can call it every frame for free.
This commit is contained in:
@@ -15,6 +15,7 @@ import android.widget.Button
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.TextView
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import com.armsx2.i18n.I18n
|
||||
import com.armsx2.runtime.MainActivityRuntime
|
||||
import kr.co.iefriends.pcsx2.NativeApp
|
||||
@@ -34,18 +35,84 @@ import kr.co.iefriends.pcsx2.NativeApp
|
||||
*/
|
||||
object SecondScreen {
|
||||
|
||||
// Panel palette. Deliberately not pulled from the Compose theme: a Presentation is outside
|
||||
// the Compose tree entirely, and reaching into MaterialTheme from a plain View would mean
|
||||
// holding a composition alive just to read six colours.
|
||||
private const val BG_TOP = 0xFF11151C.toInt()
|
||||
private const val BG_BOTTOM = 0xFF080A0E.toInt()
|
||||
private const val TILE_ACTION = 0xFF1B2331.toInt()
|
||||
private const val TILE_STAT = 0xFF141A23.toInt()
|
||||
private const val BORDER = 0x33FFFFFF
|
||||
private const val ACCENT = 0xFF7FB2FF.toInt()
|
||||
private const val ACCENT_DIM = 0x557FB2FF
|
||||
private const val TEXT = 0xFFE6EAF0.toInt()
|
||||
private const val TEXT_DIM = 0xFF9AA0A6.toInt()
|
||||
// Panel palette, taken from the app's LIVE theme.
|
||||
//
|
||||
// This used to be a hand-written set of neutral greys, on the reasoning that a Presentation
|
||||
// sits outside the Compose tree and reading MaterialTheme from a plain View would mean
|
||||
// holding a composition alive just to get six colours. The reasoning was right; the
|
||||
// conclusion was not. ARMSX2's night theme is BLUE (0xFF0A1C36), so a grey panel was not a
|
||||
// neutral choice, it was a different app on the second screen -- which is what "it still
|
||||
// looks quite unpleasant... more like stock android instead of armsx2" was describing.
|
||||
//
|
||||
// ThemeBridge publishes the already-resolved scheme, so there is no composition to hold and
|
||||
// no second copy of the theme logic to drift: the panel follows Blue, Purple, OLED, Custom,
|
||||
// Material You and the animated RGB mode without knowing any of them exist. The old values
|
||||
// stay as the fallback for the window between process start and the first composition.
|
||||
private fun themed(
|
||||
fallback: Int,
|
||||
pick: (androidx.compose.material3.ColorScheme) -> androidx.compose.ui.graphics.Color,
|
||||
): Int = com.armsx2.ui.theme.ThemeBridge.scheme?.let { pick(it).toArgb() } ?: fallback
|
||||
|
||||
/** Scale a colour's RGB toward black, keeping alpha. For the ground gradient. */
|
||||
private fun Int.darken(factor: Float): Int {
|
||||
val a = this ushr 24 and 0xFF
|
||||
val r = ((this shr 16 and 0xFF) * factor).toInt().coerceIn(0, 255)
|
||||
val g = ((this shr 8 and 0xFF) * factor).toInt().coerceIn(0, 255)
|
||||
val b = ((this and 0xFF) * factor).toInt().coerceIn(0, 255)
|
||||
return (a shl 24) or (r shl 16) or (g shl 8) or b
|
||||
}
|
||||
|
||||
private val BG_TOP get() = themed(0xFF11151C.toInt()) { it.background }
|
||||
private val BG_BOTTOM get() = BG_TOP.darken(0.55f)
|
||||
private val TILE_ACTION get() = themed(0xFF1B2331.toInt()) { it.surfaceVariant }
|
||||
private val TILE_STAT get() = themed(0xFF141A23.toInt()) { it.surface }
|
||||
private val BORDER get() = (themed(0xFFFFFFFF.toInt()) { it.outline } and 0x00FFFFFF) or 0x33000000
|
||||
private val ACCENT get() = themed(0xFF7FB2FF.toInt()) { it.primary }
|
||||
private val ACCENT_DIM get() = (ACCENT and 0x00FFFFFF) or 0x55000000
|
||||
private val TEXT get() = themed(0xFFE6EAF0.toInt()) { it.onSurface }
|
||||
private val TEXT_DIM get() = themed(0xFF9AA0A6.toInt()) { it.onSurfaceVariant }
|
||||
|
||||
// ---- Background choice (requested alongside the restyle) --------------------------------
|
||||
/** 0 = the theme's own ground, 1 = the library's backdrop darkened, 2 = solid black. */
|
||||
private const val PREF_BACKGROUND = "secondScreen.background"
|
||||
const val BG_THEME = 0
|
||||
const val BG_LIBRARY = 1
|
||||
const val BG_BLACK = 2
|
||||
|
||||
val background = mutableStateOf(BG_THEME)
|
||||
|
||||
fun loadBackground() {
|
||||
background.value = runCatching {
|
||||
MainActivityRuntime.prefs.getInt(PREF_BACKGROUND, BG_THEME)
|
||||
}.getOrDefault(BG_THEME).coerceIn(BG_THEME, BG_BLACK)
|
||||
}
|
||||
|
||||
// ---- Thermal polling interval -----------------------------------------------------------
|
||||
/** Seconds between sensor reads: 1, 2, 3 or 5. Not "realtime" — these are sysfs reads on the
|
||||
* UI thread, and a temperature that moves slower than a second is not worth the syscalls. */
|
||||
private const val PREF_TEMP_INTERVAL = "secondScreen.tempInterval"
|
||||
val tempIntervalSec = mutableStateOf(2)
|
||||
|
||||
fun loadTempInterval() {
|
||||
tempIntervalSec.value = runCatching {
|
||||
MainActivityRuntime.prefs.getInt(PREF_TEMP_INTERVAL, 2)
|
||||
}.getOrDefault(2).coerceIn(1, 5)
|
||||
}
|
||||
|
||||
fun setTempInterval(seconds: Int) {
|
||||
tempIntervalSec.value = seconds.coerceIn(1, 5)
|
||||
runCatching {
|
||||
MainActivityRuntime.prefs.edit().putInt(PREF_TEMP_INTERVAL, tempIntervalSec.value).apply()
|
||||
}
|
||||
}
|
||||
|
||||
private fun tempIntervalMs(): Long = tempIntervalSec.value * 1000L
|
||||
|
||||
fun setBackground(value: Int) {
|
||||
background.value = value.coerceIn(BG_THEME, BG_BLACK)
|
||||
runCatching { MainActivityRuntime.prefs.edit().putInt(PREF_BACKGROUND, background.value).apply() }
|
||||
rebuild()
|
||||
}
|
||||
|
||||
private const val PREF_KEY = "secondScreen.enabled"
|
||||
private const val PREF_OSD_KEY = "secondScreen.moveOsd"
|
||||
@@ -91,6 +158,8 @@ object SecondScreen {
|
||||
enabled.value = MainActivityRuntime.prefs.getBoolean(PREF_KEY, false)
|
||||
moveOsd.value = MainActivityRuntime.prefs.getBoolean(PREF_OSD_KEY, true)
|
||||
}
|
||||
loadBackground()
|
||||
loadTempInterval()
|
||||
}
|
||||
|
||||
fun set(context: Context, value: Boolean) {
|
||||
@@ -256,10 +325,7 @@ object SecondScreen {
|
||||
// matching the app: dark ground, rounded surface tiles, one accent.
|
||||
val rootView = LinearLayout(context).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
background = android.graphics.drawable.GradientDrawable(
|
||||
android.graphics.drawable.GradientDrawable.Orientation.TOP_BOTTOM,
|
||||
intArrayOf(BG_TOP, BG_BOTTOM),
|
||||
)
|
||||
background = panelBackground(context)
|
||||
setPadding(pad, pad, pad, pad)
|
||||
}
|
||||
|
||||
@@ -329,7 +395,7 @@ object SecondScreen {
|
||||
}
|
||||
val label = I18n.get(tile.labelKey)
|
||||
return TextView(context).styleAsTile(action = true).also { view ->
|
||||
(view as TextView).text = label
|
||||
(view as TextView).text = tileFace(tile.icon, label)
|
||||
view.setOnClickListener { runCatching { fire(tile) } }
|
||||
}
|
||||
}
|
||||
@@ -342,6 +408,52 @@ object SecondScreen {
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* A tile's face: the glyph on its own line, larger and in the accent, over the label.
|
||||
*
|
||||
* Built as a Spannable rather than two stacked TextViews because the grid measures one
|
||||
* view per tile, and the two-line shape is what keeps every tile the same height. When a
|
||||
* tile has no glyph (the stat tiles) this is just the text, so callers need no branch.
|
||||
*/
|
||||
private fun tileFace(icon: String, label: String): CharSequence {
|
||||
if (icon.isEmpty()) return label
|
||||
val text = "$icon\n$label"
|
||||
return android.text.SpannableString(text).apply {
|
||||
setSpan(
|
||||
android.text.style.RelativeSizeSpan(1.55f), 0, icon.length,
|
||||
android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel's ground, per the user's choice.
|
||||
*
|
||||
* The library's backdrop is offered because the panel sits next to the library and
|
||||
* looking like a different app was the complaint; it is darkened rather than drawn as-is
|
||||
* so tile text stays readable over whatever image is behind it. Solid black is for OLED
|
||||
* second screens, where a gradient is just power spent on something nobody asked to see.
|
||||
*/
|
||||
private fun panelBackground(context: Context): android.graphics.drawable.Drawable =
|
||||
when (background.value) {
|
||||
BG_BLACK -> android.graphics.drawable.ColorDrawable(Color.BLACK)
|
||||
BG_LIBRARY -> runCatching {
|
||||
val art = androidx.core.content.ContextCompat.getDrawable(
|
||||
context, com.armsx2.R.drawable.library_bg_xmb,
|
||||
) ?: throw IllegalStateException("no backdrop")
|
||||
android.graphics.drawable.LayerDrawable(
|
||||
arrayOf(art, android.graphics.drawable.ColorDrawable(0xB0000000.toInt())),
|
||||
)
|
||||
}.getOrElse { themeGround() }
|
||||
else -> themeGround()
|
||||
}
|
||||
|
||||
private fun themeGround(): android.graphics.drawable.Drawable =
|
||||
android.graphics.drawable.GradientDrawable(
|
||||
android.graphics.drawable.GradientDrawable.Orientation.TOP_BOTTOM,
|
||||
intArrayOf(BG_TOP, BG_BOTTOM),
|
||||
)
|
||||
|
||||
/** Common tile chrome: rounded surface, hairline border, centred text. */
|
||||
private fun View.styleAsTile(action: Boolean): View = apply {
|
||||
background = android.graphics.drawable.GradientDrawable().apply {
|
||||
@@ -465,6 +577,11 @@ object SecondScreen {
|
||||
|
||||
val fps = runCatching { NativeApp.getFPS() }.getOrDefault(0f)
|
||||
val title = MainActivityRuntime.currentGame.value?.title.orEmpty()
|
||||
// Thermals are file reads, so they run on their own interval rather than on every
|
||||
// panel tick — that interval IS the mitigation Cotcho asked about. Cheap to call:
|
||||
// Thermals.poll returns immediately until the interval is up.
|
||||
runCatching { Thermals.poll(context, tempIntervalMs()) }
|
||||
|
||||
// Read charge straight from BatteryManager rather than plumbing state over from the
|
||||
// main-display status cluster — this panel ticks on its own and the call is cheap.
|
||||
val battery = runCatching {
|
||||
@@ -480,7 +597,7 @@ object SecondScreen {
|
||||
.format(java.util.Date(System.currentTimeMillis()))
|
||||
|
||||
tileViews.forEach { (tile, view) ->
|
||||
val text: String? = when (tile) {
|
||||
val text: CharSequence? = when (tile) {
|
||||
SecondScreenTile.TITLE -> title.ifBlank { I18n.get("secondScreen.tile.title") }
|
||||
// FPS is meaningless with no VM — the reading would just sit at the last value.
|
||||
SecondScreenTile.FPS ->
|
||||
@@ -494,18 +611,30 @@ object SecondScreen {
|
||||
SecondScreenTile.BATTERY ->
|
||||
if (battery >= 0) batteryIcon(battery, charging) + "\n" + battery + "%" else null
|
||||
SecondScreenTile.CLOCK -> clock
|
||||
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) ?: "—")
|
||||
SecondScreenTile.ACHIEVEMENTS -> achievementSummary()
|
||||
// Action tiles that carry state show it, so the panel reads as a status
|
||||
// display and not just a remote control.
|
||||
SecondScreenTile.FAST_FORWARD -> I18n.get(tile.labelKey) +
|
||||
// State is carried by the GLYPH, not by an extra line. Appending one was
|
||||
// what made an active tile taller than its neighbours.
|
||||
SecondScreenTile.FAST_FORWARD -> tileFace(
|
||||
if (runCatching { MainActivityRuntime.isFastForwardActive() }.getOrDefault(false))
|
||||
"\n▶▶" else ""
|
||||
SecondScreenTile.PAUSE -> I18n.get(tile.labelKey) +
|
||||
if (MainActivityRuntime.eState.value == EmuState.PAUSED) "\n❚❚" else ""
|
||||
"▶▶▶" else tile.icon,
|
||||
I18n.get(tile.labelKey),
|
||||
)
|
||||
// Shows what the tap will DO: ▶ while paused, ❚❚ while running.
|
||||
SecondScreenTile.PAUSE -> tileFace(
|
||||
if (MainActivityRuntime.eState.value == EmuState.PAUSED) "▶" else tile.icon,
|
||||
I18n.get(tile.labelKey),
|
||||
)
|
||||
SecondScreenTile.SLOT ->
|
||||
I18n.get(tile.labelKey) + "\n" + MainActivityRuntime.currentSaveSlot.intValue
|
||||
SecondScreenTile.ASPECT -> I18n.get(tile.labelKey) + "\n" +
|
||||
aspectLabel(com.armsx2.ui.InGameOverlay.settingsState.value.aspectRatio)
|
||||
tileFace(tile.icon, MainActivityRuntime.currentSaveSlot.intValue.toString())
|
||||
SecondScreenTile.ASPECT -> tileFace(
|
||||
tile.icon,
|
||||
aspectLabel(com.armsx2.ui.InGameOverlay.settingsState.value.aspectRatio),
|
||||
)
|
||||
else -> null
|
||||
}
|
||||
if (text != null && view is TextView) view.text = text
|
||||
|
||||
@@ -15,7 +15,20 @@ import com.armsx2.runtime.MainActivityRuntime
|
||||
* ORDER IS PART OF THE PREFERENCE — the stored list is what gets laid out, top-left to
|
||||
* bottom-right, which is why it is a List and not a Set.
|
||||
*/
|
||||
enum class SecondScreenTile(val id: String, val labelKey: String, val stat: Boolean = false) {
|
||||
enum class SecondScreenTile(val id: String, val labelKey: String, val stat: Boolean = false,
|
||||
/**
|
||||
* A glyph shown above the label on ACTION tiles, so a tile can be recognised at a glance
|
||||
* from across a desk -- "much easier to quickly recognize pictures than text" (NiceRon).
|
||||
*
|
||||
* Deliberately geometric Unicode rather than emoji: emoji render in their own colours and
|
||||
* their own house style, which is exactly the stock-Android look the panel was being
|
||||
* restyled away from. These take the theme accent like everything else.
|
||||
*
|
||||
* Stat tiles leave this empty -- their label IS the identifier, and a value needs the
|
||||
* second line.
|
||||
*/
|
||||
val icon: String = "",
|
||||
) {
|
||||
// Read-outs. These fill their box with live text and ignore taps.
|
||||
TITLE("title", "secondScreen.tile.title", stat = true),
|
||||
FPS("fps", "secondScreen.tile.fps", stat = true),
|
||||
@@ -23,24 +36,30 @@ enum class SecondScreenTile(val id: String, val labelKey: String, val stat: Bool
|
||||
BATTERY("battery", "secondScreen.tile.battery", stat = true),
|
||||
CLOCK("clock", "secondScreen.tile.clock", stat = true),
|
||||
ACHIEVEMENTS("achievements", "secondScreen.tile.achievements", stat = true),
|
||||
// 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.
|
||||
CPU_TEMP("cputemp", "secondScreen.tile.cpuTemp", stat = true),
|
||||
GPU_TEMP("gputemp", "secondScreen.tile.gpuTemp", stat = true),
|
||||
BATTERY_TEMP("battemp", "secondScreen.tile.batteryTemp", stat = true),
|
||||
|
||||
// Actions.
|
||||
SAVE("save", "touch.stateAction.save"),
|
||||
LOAD("load", "touch.stateAction.load"),
|
||||
FAST_FORWARD("ff", "secondScreen.fastForward"),
|
||||
PAUSE("pause", "secondScreen.pause"),
|
||||
SCREENSHOT("screenshot", "touch.stateAction.screenshot"),
|
||||
ASPECT("aspect", "secondScreen.tile.aspect"),
|
||||
SLOT("slot", "secondScreen.tile.slot"),
|
||||
SAVE("save", "touch.stateAction.save", icon = "▼"),
|
||||
LOAD("load", "touch.stateAction.load", icon = "▲"),
|
||||
FAST_FORWARD("ff", "secondScreen.fastForward", icon = "▶▶"),
|
||||
PAUSE("pause", "secondScreen.pause", icon = "❚❚"),
|
||||
SCREENSHOT("screenshot", "touch.stateAction.screenshot", icon = "◉"),
|
||||
ASPECT("aspect", "secondScreen.tile.aspect", icon = "▭"),
|
||||
SLOT("slot", "secondScreen.tile.slot", icon = "▣"),
|
||||
// The way out from the panel itself — asked for after the panel landed on the display the game
|
||||
// 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"),
|
||||
HIDE("hide", "secondScreen.tile.hide", icon = "✕"),
|
||||
|
||||
MACRO1("macro1", "secondScreen.tile.macro1"),
|
||||
MACRO2("macro2", "secondScreen.tile.macro2"),
|
||||
MACRO3("macro3", "secondScreen.tile.macro3"),
|
||||
MACRO4("macro4", "secondScreen.tile.macro4"),
|
||||
MACRO1("macro1", "secondScreen.tile.macro1", icon = "①"),
|
||||
MACRO2("macro2", "secondScreen.tile.macro2", icon = "②"),
|
||||
MACRO3("macro3", "secondScreen.tile.macro3", icon = "③"),
|
||||
MACRO4("macro4", "secondScreen.tile.macro4", icon = "④"),
|
||||
}
|
||||
|
||||
object SecondScreenLayout {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.armsx2
|
||||
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* CPU / GPU / battery temperatures, for the panel's stat tiles.
|
||||
*
|
||||
* Asked for by two people at once (Cotcho: "temp sensor on applicable device as part of stats
|
||||
* OSD... maybe intervals in polling the sensors could help mitigate"; Mike22: "more info from
|
||||
* the OSD available on the second screen").
|
||||
*
|
||||
* Android has no supported API for this. HardwarePropertiesManager exists but is gated behind
|
||||
* DEVICE_POWER, which is signature-level, so an app cannot use it. What is left is the thermal
|
||||
* sysfs, which is readable without permission on essentially every device but is not a contract:
|
||||
* zone COUNT, zone ORDER and zone NAMING are all vendor-specific, and the unit is not fixed
|
||||
* either. So this discovers zones once by name, tolerates every failure by simply having no
|
||||
* reading, and never claims a value it could not actually read.
|
||||
*
|
||||
* "Not available on this device" is a normal outcome here, not an error.
|
||||
*/
|
||||
object Thermals {
|
||||
|
||||
/** No reading. Distinguished from a real 0°C, which a phone will not be at. */
|
||||
const val NONE = Float.MIN_VALUE
|
||||
|
||||
private const val ZONES = "/sys/class/thermal"
|
||||
|
||||
/** Substrings that identify a zone, in preference order. Qualcomm, MediaTek, Exynos and
|
||||
* Tensor all name theirs differently, and several expose a dozen CPU zones (one per
|
||||
* cluster or core); the first match is taken because a single representative reading is
|
||||
* what a stat tile wants, not the hottest-of-twelve. */
|
||||
private val CPU_HINTS = listOf("cpu-0-0", "cpuss", "mtktscpu", "cpu_thermal", "cpu")
|
||||
private val GPU_HINTS = listOf("gpuss", "mtktsgpu", "gpu_thermal", "gpu")
|
||||
|
||||
private var scanned = false
|
||||
private var cpuZone: File? = null
|
||||
private var gpuZone: File? = null
|
||||
|
||||
/** Last readings, and when they were taken. Kept so a caller polling faster than the
|
||||
* interval gets the previous value rather than hitting sysfs on every frame. */
|
||||
@Volatile var cpu: Float = NONE; private set
|
||||
@Volatile var gpu: Float = NONE; private set
|
||||
@Volatile var battery: Float = NONE; private set
|
||||
private var lastPollMs = 0L
|
||||
|
||||
/** True once a scan has happened and found nothing, so the UI can hide the tiles rather
|
||||
* than showing three permanent dashes. */
|
||||
val available: Boolean get() = cpu != NONE || gpu != NONE || battery != NONE
|
||||
|
||||
private fun scan() {
|
||||
if (scanned) return
|
||||
scanned = true
|
||||
val zones = runCatching {
|
||||
File(ZONES).listFiles { f -> f.name.startsWith("thermal_zone") }?.sortedBy { it.name }
|
||||
}.getOrNull().orEmpty()
|
||||
// type -> zone dir, read once. A zone whose type is unreadable is simply skipped.
|
||||
val named = zones.mapNotNull { z ->
|
||||
val type = runCatching { File(z, "type").readText().trim().lowercase() }.getOrNull()
|
||||
if (type.isNullOrEmpty()) null else type to z
|
||||
}
|
||||
fun pick(hints: List<String>): File? {
|
||||
for (h in hints) named.firstOrNull { it.first.contains(h) }?.let { return it.second }
|
||||
return null
|
||||
}
|
||||
cpuZone = pick(CPU_HINTS)
|
||||
gpuZone = pick(GPU_HINTS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert whatever the kernel wrote into degrees Celsius.
|
||||
*
|
||||
* The unit is genuinely not standard: most zones report millidegrees (45000), some report
|
||||
* tenths (450), a few report plain degrees (45). Rather than guess per vendor, the magnitude
|
||||
* decides — no phone runs at 1000°C, and none idles at 0.045°C, so the ranges do not overlap.
|
||||
*/
|
||||
private fun toCelsius(raw: Long): Float = when {
|
||||
raw > 10_000 -> raw / 1000f
|
||||
raw > 1_000 -> raw / 100f
|
||||
raw > 200 -> raw / 10f
|
||||
else -> raw.toFloat()
|
||||
}
|
||||
|
||||
private fun read(zone: File?): Float {
|
||||
val f = zone ?: return NONE
|
||||
val raw = runCatching { File(f, "temp").readText().trim().toLong() }.getOrNull() ?: return NONE
|
||||
val c = toCelsius(raw)
|
||||
// A plausibility gate. Some zones are not temperatures at all (fan RPM, a cooling-device
|
||||
// state), and a tile reading "912°C" is worse than a tile reading nothing.
|
||||
return if (c in -20f..150f) c else NONE
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh if [intervalMs] has passed. Cheap to call often — the rate limit is the point,
|
||||
* since these are file reads and the caller is a UI tick.
|
||||
*/
|
||||
fun poll(context: Context, intervalMs: Long) {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
if (now - lastPollMs < intervalMs) return
|
||||
lastPollMs = now
|
||||
scan()
|
||||
cpu = read(cpuZone)
|
||||
gpu = read(gpuZone)
|
||||
// Battery is the one with a real API. Tenths of a degree, per the documented extra.
|
||||
battery = runCatching {
|
||||
val i = context.registerReceiver(null, android.content.IntentFilter(android.content.Intent.ACTION_BATTERY_CHANGED))
|
||||
val tenths = i?.getIntExtra(android.os.BatteryManager.EXTRA_TEMPERATURE, Int.MIN_VALUE)
|
||||
?: Int.MIN_VALUE
|
||||
if (tenths == Int.MIN_VALUE) NONE else (tenths / 10f).takeIf { it in -20f..150f } ?: NONE
|
||||
}.getOrDefault(NONE)
|
||||
}
|
||||
|
||||
/** "48°" or null when there is no reading. */
|
||||
fun format(c: Float): String? = if (c == NONE) null else "${c.toInt()}°"
|
||||
}
|
||||
@@ -1703,6 +1703,14 @@ private val BASE_EN: Map<String, String> = mapOf(
|
||||
"touch.editor.scopeGlobal" to "Editing Global Default touch layout",
|
||||
"touch.editor.show" to "Show",
|
||||
"touch.editor.tapHoldOff" to "Tap-Hold Off",
|
||||
"secondScreen.tile.cpuTemp" to "CPU temp",
|
||||
"secondScreen.tile.gpuTemp" to "GPU temp",
|
||||
"secondScreen.tile.batteryTemp" to "Battery temp",
|
||||
"secondScreen.tempInterval" to "Sensor refresh",
|
||||
"secondScreen.background" to "Panel background",
|
||||
"secondScreen.background.theme" to "Theme",
|
||||
"secondScreen.background.library" to "Library",
|
||||
"secondScreen.background.black" to "Black",
|
||||
"touch.editor.turboOff" to "Turbo Off",
|
||||
"touch.editor.turboFast" to "Turbo Fast",
|
||||
"touch.editor.turboMed" to "Turbo Med",
|
||||
|
||||
@@ -472,6 +472,38 @@ fun AppTab() {
|
||||
onChange = { com.armsx2.SecondScreen.setMoveOsd(it) },
|
||||
)
|
||||
|
||||
// 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.
|
||||
SegmentedRow(
|
||||
label = str("secondScreen.background"),
|
||||
options = listOf(
|
||||
str("secondScreen.background.theme"),
|
||||
str("secondScreen.background.library"),
|
||||
str("secondScreen.background.black"),
|
||||
),
|
||||
selectedIndex = com.armsx2.SecondScreen.background.value,
|
||||
onChange = { com.armsx2.SecondScreen.setBackground(it) },
|
||||
)
|
||||
|
||||
// Only worth showing once a thermal tile is actually on the panel — otherwise it is
|
||||
// a control over something invisible.
|
||||
if (com.armsx2.SecondScreenLayout.tiles().any {
|
||||
it == com.armsx2.SecondScreenTile.CPU_TEMP ||
|
||||
it == com.armsx2.SecondScreenTile.GPU_TEMP ||
|
||||
it == com.armsx2.SecondScreenTile.BATTERY_TEMP
|
||||
}
|
||||
) {
|
||||
val seconds = listOf(1, 2, 3, 5)
|
||||
SegmentedRow(
|
||||
label = str("secondScreen.tempInterval"),
|
||||
options = seconds.map { "${it}s" },
|
||||
selectedIndex = seconds.indexOf(com.armsx2.SecondScreen.tempIntervalSec.value)
|
||||
.coerceAtLeast(0),
|
||||
onChange = { com.armsx2.SecondScreen.setTempInterval(seconds[it]) },
|
||||
)
|
||||
}
|
||||
|
||||
// Panel layout editor. Chips for what is on the panel, arrows for the order, a column
|
||||
// count for the shape of the grid. Asked for as "a grid which can be filled with boxes
|
||||
// containing the things one need" (NiceRon) — the point is that the panel is different
|
||||
|
||||
@@ -42,6 +42,17 @@ enum class ThemeMode { System, MaterialYou, Rgb, Custom, Light, Blue, Purple, Pi
|
||||
val followsSystemDarkMode: Boolean get() = this == System || this == MaterialYou
|
||||
}
|
||||
|
||||
/**
|
||||
* The live [ColorScheme], for code that cannot be a @Composable.
|
||||
*
|
||||
* Written by [Armsx2Theme] on every recomposition that changes the theme, read by the
|
||||
* second-screen Presentation. Null until the first composition, so readers need a fallback.
|
||||
*/
|
||||
object ThemeBridge {
|
||||
@Volatile
|
||||
var scheme: androidx.compose.material3.ColorScheme? = null
|
||||
}
|
||||
|
||||
object ThemePreferences {
|
||||
private const val PreferenceKey = "ui.theme.mode"
|
||||
|
||||
@@ -553,14 +564,22 @@ fun Armsx2Theme(content: @Composable () -> Unit) {
|
||||
remember(step) { hueScheme(step * RgbHueStep, 0.62f, 0.92f) }
|
||||
}
|
||||
}
|
||||
// OLED base is a modifier over the resolved scheme, so it applies to every mode above —
|
||||
// including MaterialYou, Custom and the animated Rgb one. Light themes are left alone;
|
||||
// forcing black surfaces under light-theme text would be unreadable.
|
||||
val resolved = if (ThemePreferences.oledBase.value && scheme.isDarkScheme())
|
||||
scheme.withOledBase()
|
||||
else
|
||||
scheme
|
||||
// Publish it for the parts of the app that are NOT Compose. The second-screen panel is
|
||||
// classic Views inside a Presentation, so it cannot read MaterialTheme, and it used to carry
|
||||
// a hand-written palette of its own -- neutral greys against an app whose night theme is
|
||||
// blue. That is why it read as a stock Android dialog rather than as ARMSX2. Publishing the
|
||||
// RESOLVED scheme (rather than re-deriving it there) means the panel follows every mode,
|
||||
// including MaterialYou, Custom, OLED and the animated RGB one, for free.
|
||||
ThemeBridge.scheme = resolved
|
||||
MaterialTheme(
|
||||
// OLED base is a modifier over the resolved scheme, so it applies to every mode above —
|
||||
// including MaterialYou, Custom and the animated Rgb one. Light themes are left alone;
|
||||
// forcing black surfaces under light-theme text would be unreadable.
|
||||
colorScheme = if (ThemePreferences.oledBase.value && scheme.isDarkScheme())
|
||||
scheme.withOledBase()
|
||||
else
|
||||
scheme,
|
||||
colorScheme = resolved,
|
||||
typography = ArmsTypography,
|
||||
content = content,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user