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:
jpolo1224
2026-08-24 10:36:30 -04:00
parent 7bdcd1be7c
commit f2b092cb6e
6 changed files with 368 additions and 45 deletions
@@ -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,
)