From 5be04466af4ff62f5562e47da9dc9df11246da76 Mon Sep 17 00:00:00 2001 From: The412Banner Date: Sat, 31 Oct 2026 19:42:39 +0000 Subject: [PATCH] feat(dual-screen): In-game stats strips and dashes with the DroidDeck logo The In-game stats page gets a top strip and a racing dash the user picks separately; both are remembered and cleared by Reset settings. Top strip, in FusionHUD's metric colors: Panel (an outlined panel with the DroidDeck badge beside the FPS, frame time, a frame-time graph, CPU, GPU, RAM and battery rows), Tiles (four tiles with a 30-second line) and Rings. Tiles and Rings carry the DroidDeck mark, wordmark and clock above them. Dash: Speedometer (speed dial with the rev band, gear, pedals, G-force and steering), Circuit (shift lights, gear, timing with best lap, fuel, tire temperatures), Trace (10 s of throttle and brake, speed below), Dials (speed and rev dials, OutGauge dashboard lights, turbo and temperatures) and Glance; Auto picks Speedometer for Codemasters, Circuit for Forza and Dials for OutGauge. War Thunder gets a Flight layout (IAS and altitude tapes, throttle, G, fuel) and Elite Dangerous a Ship layout (pips, fuel, cargo, legal state, credits). Every readout card shows the DroidDeck mark in its title, drawn with the HUD's own DroidDeckMark. Tapping the strip or the dash moves to the next style; a Layout row at the bottom of the page picks either directly. Telemetry now also reads Codemasters steering and G-force (DiRT Showdown's 38 floats carry them), Forza best lap, steering, G-force and tire temperatures (read as Fahrenheit, as community parsers do; unit not checked on a device), and OutGauge turbo, engine and oil temperature and dashboard lights. War Thunder keeps every number so the Flight layout finds fuel and throttle; its list still shows the first 12. --- .../launcher/session/GameTelemetry.kt | 41 +- .../launcher/session/SessionPrefs.kt | 12 + .../launcher/session/SettingsReset.kt | 2 +- .../droiddeck/launcher/session/StatsLayout.kt | 33 + .../launcher/ui/BottomTelemetryPage.kt | 169 +++-- .../com/droiddeck/launcher/ui/StatsDashes.kt | 580 ++++++++++++++++++ .../com/droiddeck/launcher/ui/StatsStrips.kt | 288 +++++++++ app/src/main/res/values/strings.xml | 37 ++ .../launcher/session/GameTelemetryTest.kt | 77 +++ .../launcher/session/StatsLayoutTest.kt | 32 + 10 files changed, 1201 insertions(+), 70 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/session/StatsLayout.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/StatsDashes.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/StatsStrips.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/session/StatsLayoutTest.kt diff --git a/app/src/main/java/com/droiddeck/launcher/session/GameTelemetry.kt b/app/src/main/java/com/droiddeck/launcher/session/GameTelemetry.kt index b7820e0f3..28777f6b0 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/GameTelemetry.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/GameTelemetry.kt @@ -18,6 +18,18 @@ data class Telemetry( val fuel: Float? = null, val currentLapS: Float? = null, val lastLapS: Float? = null, + val bestLapS: Float? = null, + /** G-force as the game sends it (lateral, longitudinal) and steering from -1 (left) to 1 (right). */ + val gLat: Float? = null, + val gLon: Float? = null, + val steer: Float? = null, + /** Front left, front right, rear left, rear right. */ + val tireTempsC: List? = null, + val turboBar: Float? = null, + val engineTempC: Float? = null, + val oilTempC: Float? = null, + /** OutGauge dashboard lights that are on, as GameTelemetry.DL_* bits. */ + val lights: Int? = null, ) /** @@ -47,14 +59,25 @@ object GameTelemetry { }.getOrNull() private const val MPS_TO_KMH = 3.6f + private const val GRAVITY = 9.81f + + // OutGauge DashLights / ShowLights bits (Live for Speed InSim; BeamNG uses the same values). + const val DL_SHIFT = 1 + const val DL_HANDBRAKE = 4 + const val DL_PITSPEED = 8 + const val DL_TC = 16 + const val DL_ABS = 1024 // Float indexes of the Codemasters packet; engine rpm values are sent divided by 10. Older games // (DiRT Showdown) send only the first 38 floats, so lap count, position, fuel and max rpm are missing there. private const val CM_LAP_TIME = 1 private const val CM_SPEED = 7 private const val CM_THROTTLE = 29 + private const val CM_STEER = 30 private const val CM_BRAKE = 31 private const val CM_GEAR = 33 + private const val CM_G_LAT = 34 + private const val CM_G_LON = 35 private const val CM_LAP = 36 private const val CM_RPM = 37 private const val CM_POSITION = 39 @@ -90,6 +113,9 @@ object GameTelemetry { fuel = if (capacity > 0f) (f(CM_FUEL) / capacity).coerceIn(0f, 1f) else null, currentLapS = f(CM_LAP_TIME).takeIf { it > 0f }, lastLapS = f(CM_LAST_LAP).takeIf { it > 0f }, + gLat = f(CM_G_LAT), + gLon = f(CM_G_LON), + steer = f(CM_STEER).coerceIn(-1f, 1f), ) } @@ -114,6 +140,8 @@ object GameTelemetry { if (dash < 0) return Telemetry(source = "Forza", rpm = rpm, maxRpm = maxRpm.takeIf { it > 0f }) if (!racing) return Telemetry(source = "Forza") val gear = buf.get(dash + 75).toInt() and 0xFF + // Tire temperatures are read as Fahrenheit, as community Data Out parsers do; not checked on a device. + val tiresF = (0 until 4).map { buf.getFloat(dash + 24 + it * 4) } return Telemetry( source = "Forza", speedKmh = buf.getFloat(dash + 12) * MPS_TO_KMH, @@ -127,6 +155,11 @@ object GameTelemetry { fuel = buf.getFloat(dash + 44).coerceIn(0f, 1f), currentLapS = buf.getFloat(dash + 60).takeIf { it > 0f }, lastLapS = buf.getFloat(dash + 56).takeIf { it > 0f }, + bestLapS = buf.getFloat(dash + 52).takeIf { it > 0f }, + gLat = buf.getFloat(20) / GRAVITY, + gLon = buf.getFloat(28) / GRAVITY, + steer = (buf.get(dash + 76).toFloat() / 127f).coerceIn(-1f, 1f), + tireTempsC = tiresF.takeIf { f -> f.any { it > 0f } }?.map { (it - 32f) * 5f / 9f }, ) } @@ -148,6 +181,10 @@ object GameTelemetry { throttle = buf.getFloat(48).coerceIn(0f, 1f), brake = buf.getFloat(52).coerceIn(0f, 1f), fuel = buf.getFloat(28).coerceIn(0f, 1f), + turboBar = buf.getFloat(20).takeIf { it != 0f }, + engineTempC = buf.getFloat(24).takeIf { it != 0f }, + oilTempC = buf.getFloat(36).takeIf { it != 0f }, + lights = buf.getInt(44).takeIf { buf.getInt(40) != 0 }, ) } } @@ -157,7 +194,7 @@ object GameStatusFiles { const val WAR_THUNDER_STATE = "http://127.0.0.1:8111/state" private const val ELITE_APP = "359320" private const val ELITE_STATUS = "pfx/drive_c/users/steamuser/Saved Games/Frontier Developments/Elite Dangerous/Status.json" - private const val WAR_THUNDER_ROWS = 12 + const val WAR_THUNDER_ROWS = 12 /** War Thunder's own labels (for example "TAS, km/h") with their values, while a match runs. */ internal fun warThunder(json: String): List> { @@ -166,7 +203,7 @@ object GameStatusFiles { return state.keys().asSequence().filter { it != "valid" }.mapNotNull { key -> val value = state.opt(key) as? Number ?: return@mapNotNull null key to if (value.toDouble() % 1.0 == 0.0) value.toLong().toString() else "%.1f".format(java.util.Locale.US, value.toDouble()) - }.take(WAR_THUNDER_ROWS).toList() + }.toList() } fun eliteStatusFile(runtimeRoot: java.io.File, mounts: Map): java.io.File? { diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index 98f695e5c..74b071efb 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -106,6 +106,18 @@ object SessionPrefs { prefs(context).edit().putString("bottomPage", page).apply() } + fun statsStrip(context: Context): String? = prefs(context).getString("statsStrip", null) + + fun setStatsStrip(context: Context, id: String) { + prefs(context).edit().putString("statsStrip", id).apply() + } + + fun statsDash(context: Context): String? = prefs(context).getString("statsDash", null) + + fun setStatsDash(context: Context, id: String) { + prefs(context).edit().putString("statsDash", id).apply() + } + /** In-game stats "Send dashboard data" Automatic: on unless the user switched it off. */ fun telemetryAuto(context: Context): Boolean = prefs(context).getBoolean("telemetryAuto", true) diff --git a/app/src/main/java/com/droiddeck/launcher/session/SettingsReset.kt b/app/src/main/java/com/droiddeck/launcher/session/SettingsReset.kt index 55efc2a5a..1756c631a 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SettingsReset.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SettingsReset.kt @@ -26,7 +26,7 @@ object SettingsReset { "upscaler", "upscaleSharpness", "screenEffects", "textureAnisotropy", "textureLodBias", "steamDownloadsInBackground", "steam_offline", "bootMovie", "bootMovieSound", "bootMovieVolume", "bootMovieSeconds", "bootMovieApplyNext", "bootMovieSteamSeen", - "questDefaultsV1", "telemetryAuto", "telemetryOff", + "questDefaultsV1", "telemetryAuto", "telemetryOff", "statsStrip", "statsDash", ) internal val SESSION_PREFIXES = listOf( diff --git a/app/src/main/java/com/droiddeck/launcher/session/StatsLayout.kt b/app/src/main/java/com/droiddeck/launcher/session/StatsLayout.kt new file mode 100644 index 000000000..d022cc139 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/session/StatsLayout.kt @@ -0,0 +1,33 @@ +package com.droiddeck.launcher.session + +/** The In-game stats page's top strip, picked on the page (tap or the Layout row) and remembered. */ +enum class StatsStrip(val id: String) { + PANEL("panel"), TILES("tiles"), RINGS("rings"); + + fun next(): StatsStrip = entries[(ordinal + 1) % entries.size] + + companion object { + fun from(id: String?): StatsStrip = entries.firstOrNull { it.id == id } ?: PANEL + } +} + +/** The racing dash under the strip; Auto picks one by the game's data format. */ +enum class StatsDash(val id: String) { + AUTO("auto"), SPEEDOMETER("speedometer"), CIRCUIT("circuit"), TRACE("trace"), DIALS("dials"), GLANCE("glance"); + + /** The dash to draw for data from [source] (Telemetry.source). */ + fun resolve(source: String): StatsDash = if (this != AUTO) this else when (source) { + "Forza" -> CIRCUIT + "OutGauge" -> DIALS + else -> SPEEDOMETER + } + + companion object { + private val DRAWN = listOf(SPEEDOMETER, CIRCUIT, TRACE, DIALS, GLANCE) + + fun from(id: String?): StatsDash = entries.firstOrNull { it.id == id } ?: AUTO + + /** A tap on the dash moves on from the one on screen, so Auto never looks like a tap did nothing. */ + fun tapped(shown: StatsDash): StatsDash = DRAWN[(DRAWN.indexOf(shown) + 1).mod(DRAWN.size)] + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/BottomTelemetryPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/BottomTelemetryPage.kt index b8069a1c1..93593b371 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/BottomTelemetryPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/BottomTelemetryPage.kt @@ -1,6 +1,9 @@ package com.droiddeck.launcher.ui import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -46,6 +49,8 @@ import com.droiddeck.launcher.session.GameStatusFiles import com.droiddeck.launcher.session.GameTelemetry import com.droiddeck.launcher.session.SessionPrefs import com.droiddeck.launcher.session.SessionState +import com.droiddeck.launcher.session.StatsDash +import com.droiddeck.launcher.session.StatsStrip import com.droiddeck.launcher.session.Telemetry import com.droiddeck.launcher.session.TelemetryOutput import kotlinx.coroutines.Dispatchers @@ -70,6 +75,8 @@ private const val THUNDER_RETRY_MS = 3_000L private const val ELITE_MS = 1_000L private const val ELITE_SEARCH_MS = 10_000L private const val OUTPUT_SCAN_MS = 3_000L +/** Top strip history: one reading a second for 30 s. */ +private const val PERF_N = 30 /** What arrives on one telemetry port; written only by that port's listener. */ private class PortTraffic { @@ -100,6 +107,18 @@ fun BottomTelemetryPage(fps: () -> Float?, runningGame: () -> String? = { null } var busyPorts by remember { mutableStateOf>(emptySet()) } val traffic = remember { GameTelemetry.PORTS.associateWith { PortTraffic() } } var unknown by remember { mutableStateOf>(emptyList()) } + var strip by remember { mutableStateOf(StatsStrip.from(SessionPrefs.statsStrip(context))) } + var dash by remember { mutableStateOf(StatsDash.from(SessionPrefs.statsDash(context))) } + val fpsHistory = remember { History(PERF_N) } + val cpuHistory = remember { History(PERF_N) } + val gpuHistory = remember { History(PERF_N) } + val wattsHistory = remember { History(PERF_N) } + val throttleTrace = remember { History(TRACE_N) } + val brakeTrace = remember { History(TRACE_N) } + val speedTrace = remember { History(TRACE_N) } + var perfTick by remember { mutableIntStateOf(0) } + var traceTick by remember { mutableIntStateOf(0) } + var clock by remember { mutableStateOf("") } LaunchedEffect(Unit) { for (port in GameTelemetry.PORTS) launch(Dispatchers.IO) { @@ -137,6 +156,12 @@ fun BottomTelemetryPage(fps: () -> Float?, runningGame: () -> String? = { null } while (isActive) { val reading = latest.get() race = reading?.takeIf { System.currentTimeMillis() - it.first < TELEMETRY_STALE_MS }?.second + race?.let { r -> + throttleTrace.add(r.throttle ?: 0f) + brakeTrace.add(r.brake ?: 0f) + speedTrace.add(r.speedKmh ?: 0f) + traceTick++ + } fpsNow = fps() unknown = traffic.filter { (_, t) -> t.packets > 0 && !t.readable }.map { (port, t) -> UnknownTraffic(port, t.packets, t.length) } delay(DRAW_MS) @@ -146,6 +171,14 @@ fun BottomTelemetryPage(fps: () -> Float?, runningGame: () -> String? = { null } val metrics = HudMetrics(context).apply { setMangoReadings(MangoReadings(File(context.filesDir, "session"), LinuxRuntime.rootDir(context))) } while (isActive) { device = withContext(Dispatchers.IO) { runCatching { metrics.snapshot() }.getOrNull() } + fps()?.takeIf { it > 0f }?.let { fpsHistory.add(it) } + device?.let { s -> + s.cpuPercent?.let { cpuHistory.add(it.toFloat()) } + s.gpuPercent?.let { gpuHistory.add(it.toFloat()) } + s.battery?.watts?.takeIf { it > 0f }?.let { wattsHistory.add(it) } + } + clock = java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT).format(java.util.Date()) + perfTick++ delay(METRICS_MS) } } @@ -189,11 +222,28 @@ fun BottomTelemetryPage(fps: () -> Float?, runningGame: () -> String? = { null } } Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) { - DeviceRow(fpsNow, device) - race?.let { RaceDash(it) } - if (thunder.isNotEmpty()) TelemetryCard(stringResource(R.string.bottom_telemetry_war_thunder)) { ValueGrid(thunder) } + perfTick.let { + StatsStripView(strip, StripData(fpsNow, device, fpsHistory.snapshot(), cpuHistory.snapshot(), gpuHistory.snapshot(), wattsHistory.snapshot(), clock)) { + strip = strip.next() + SessionPrefs.setStatsStrip(context, strip.id) + } + } + race?.let { r -> + val shown = dash.resolve(r.source) + traceTick.let { + RaceDashView(shown, r, TraceData(throttleTrace.snapshot(), brakeTrace.snapshot(), speedTrace.snapshot())) { + dash = StatsDash.tapped(shown) + SessionPrefs.setStatsDash(context, dash.id) + } + } + } + if (thunder.isNotEmpty()) { + if (FlightLabels.drawable(thunder)) FlightDash(thunder) + else TelemetryCard(stringResource(R.string.bottom_telemetry_war_thunder)) { ValueGrid(thunder.take(GameStatusFiles.WAR_THUNDER_ROWS)) } + } elite?.let { e -> - TelemetryCard(stringResource(R.string.bottom_telemetry_elite)) { + if (e.pips != null || e.fuelMain != null) ShipDash(e) + else TelemetryCard(stringResource(R.string.bottom_telemetry_elite)) { ValueGrid(listOfNotNull( e.fuelMain?.let { stringResource(R.string.bottom_telemetry_fuel) to "%.1f t".format(it) }, e.cargo?.let { stringResource(R.string.bottom_telemetry_cargo) to "%.0f t".format(it) }, @@ -250,6 +300,54 @@ fun BottomTelemetryPage(fps: () -> Float?, runningGame: () -> String? = { null } Text(stringResource(R.string.bottom_telemetry_port_busy, busyPorts.sorted().joinToString()), fontSize = 13.sp, color = colors.error, modifier = Modifier.padding(horizontal = 4.dp)) } + LayoutPicker(strip, dash, + onStrip = { strip = it; SessionPrefs.setStatsStrip(context, it.id) }, + onDash = { dash = it; SessionPrefs.setStatsDash(context, it.id) }) + } +} + +@Composable +private fun LayoutPicker(strip: StatsStrip, dash: StatsDash, onStrip: (StatsStrip) -> Unit, onDash: (StatsDash) -> Unit) { + val colors = MaterialTheme.colorScheme + TelemetryCard(stringResource(R.string.stats_layout)) { + PickRow(stringResource(R.string.stats_layout_top), StatsStrip.entries.map { it to stringResource(stripLabel(it)) }, strip, onStrip) + PickRow(stringResource(R.string.stats_layout_dash), StatsDash.entries.map { it to stringResource(dashLabel(it)) }, dash, onDash) + Text(stringResource(R.string.stats_layout_hint), fontSize = 12.sp, color = colors.onSurfaceVariant) + } +} + +private fun stripLabel(s: StatsStrip): Int = when (s) { + StatsStrip.PANEL -> R.string.stats_strip_panel + StatsStrip.TILES -> R.string.stats_strip_tiles + StatsStrip.RINGS -> R.string.stats_strip_rings +} + +private fun dashLabel(d: StatsDash): Int = when (d) { + StatsDash.AUTO -> R.string.stats_dash_auto + StatsDash.SPEEDOMETER -> R.string.stats_dash_speedometer + StatsDash.CIRCUIT -> R.string.stats_dash_circuit + StatsDash.TRACE -> R.string.stats_dash_trace + StatsDash.DIALS -> R.string.stats_dash_dials + StatsDash.GLANCE -> R.string.stats_dash_glance +} + +@Composable +private fun PickRow(title: String, options: List>, selected: T, onPick: (T) -> Unit) { + val pal = LocalPalette.current + val colors = MaterialTheme.colorScheme + val shape = RoundedCornerShape(50) + Row(Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Text(title, fontSize = 13.sp, color = colors.onSurfaceVariant, modifier = Modifier.width(44.dp)) + for ((value, label) in options) { + val on = value == selected + Text(label, fontSize = 13.sp, fontWeight = if (on) FontWeight.Bold else FontWeight.Normal, + color = if (on) colors.background else colors.onBackground, maxLines = 1, + modifier = Modifier.clip(shape).background(if (on) pal.primary else Color.Transparent) + .border(1.dp, if (on) pal.primary else pal.line, shape) + .clickable { onPick(value) } + .padding(horizontal = 12.dp, vertical = 6.dp)) + } } } @@ -264,18 +362,6 @@ private fun httpGet(url: String): String { } } -@Composable -private fun DeviceRow(fps: Float?, s: HudMetrics.Snapshot?) { - Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - TelemetryTile(stringResource(R.string.bottom_telemetry_fps), fps?.takeIf { it > 0f }?.let { "%.0f".format(it) } ?: "–", Modifier.weight(1f)) - TelemetryTile(stringResource(R.string.bottom_telemetry_cpu), listOfNotNull(s?.cpuPercent?.let { "$it%" }, s?.cpuTempC?.let { "$it°" }) - .joinToString(" ").ifEmpty { "–" }, Modifier.weight(1f)) - TelemetryTile(stringResource(R.string.bottom_telemetry_gpu), listOfNotNull(s?.gpuPercent?.let { "$it%" }, s?.gpuTempC?.let { "$it°" }) - .joinToString(" ").ifEmpty { "–" }, Modifier.weight(1f)) - TelemetryTile(stringResource(R.string.bottom_telemetry_battery), listOfNotNull(s?.battery?.percent?.let { "$it%" }, - s?.battery?.watts?.takeIf { it > 0f }?.let { "%.1fW".format(it) }).joinToString(" ").ifEmpty { "–" }, Modifier.weight(1f)) - } -} @Composable private fun SwitchRow(title: String, hint: String?, checked: Boolean, enabled: Boolean, onChange: (Boolean) -> Unit) { @@ -292,14 +378,6 @@ private fun SwitchRow(title: String, hint: String?, checked: Boolean, enabled: B } } -@Composable -private fun TelemetryTile(label: String, value: String, modifier: Modifier) { - val colors = MaterialTheme.colorScheme - Column(modifier.clip(RoundedCornerShape(12.dp)).background(LocalPalette.current.surface).padding(horizontal = 10.dp, vertical = 8.dp)) { - Text(label, fontSize = 11.sp, color = colors.onSurfaceVariant, maxLines = 1) - Text(value, fontSize = 17.sp, fontWeight = FontWeight.Bold, color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis) - } -} @Composable private fun TelemetryCard(title: String, content: @Composable () -> Unit) { @@ -332,48 +410,5 @@ internal fun lapTime(seconds: Float): String { return "%d:%02d.%03d".format(total / 60_000, total / 1000 % 60, total % 1000) } -@Composable -private fun RaceDash(t: Telemetry) { - val colors = MaterialTheme.colorScheme - val pal = LocalPalette.current - TelemetryCard(t.source) { - Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) { - Text(t.gear ?: "–", fontSize = 56.sp, fontWeight = FontWeight.Bold, color = pal.signal) - Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text(t.speedKmh?.let { "%.0f km/h".format(it) } ?: "–", fontSize = 30.sp, fontWeight = FontWeight.Bold, color = colors.onBackground) - val rpm = t.rpm - if (rpm != null) { - val max = t.maxRpm - Text(if (max != null) "%.0f / %.0f rpm".format(rpm, max) else "%.0f rpm".format(rpm), fontSize = 14.sp, color = colors.onSurfaceVariant) - if (max != null) Bar((rpm / max).coerceIn(0f, 1f), if (rpm / max > 0.92f) colors.error else pal.signal) - } - } - Row(Modifier.height(72.dp), horizontalArrangement = Arrangement.spacedBy(6.dp)) { - t.throttle?.let { Pedal(it, pal.good) } - t.brake?.let { Pedal(it, colors.error) } - } - } - ValueGrid(listOfNotNull( - t.lap?.let { lap -> stringResource(R.string.bottom_telemetry_lap) to (t.laps?.let { "$lap / $it" } ?: "$lap") }, - t.position?.let { stringResource(R.string.bottom_telemetry_position) to "P$it" }, - t.currentLapS?.let { stringResource(R.string.bottom_telemetry_this_lap) to lapTime(it) }, - t.lastLapS?.let { stringResource(R.string.bottom_telemetry_last_lap) to lapTime(it) }, - t.fuel?.let { stringResource(R.string.bottom_telemetry_fuel) to "%.0f%%".format(it * 100) }, - )) - } -} -@Composable -private fun Bar(fraction: Float, color: Color) { - Box(Modifier.fillMaxWidth().height(10.dp).clip(RoundedCornerShape(5.dp)).background(LocalPalette.current.line)) { - Box(Modifier.fillMaxWidth(fraction).fillMaxHeight().background(color)) - } -} -@Composable -private fun Pedal(fraction: Float, color: Color) { - Box(Modifier.width(14.dp).fillMaxHeight().clip(RoundedCornerShape(4.dp)).background(LocalPalette.current.line), - contentAlignment = Alignment.BottomCenter) { - Box(Modifier.fillMaxWidth().fillMaxHeight(fraction.coerceIn(0f, 1f)).background(color)) - } -} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StatsDashes.kt b/app/src/main/java/com/droiddeck/launcher/ui/StatsDashes.kt new file mode 100644 index 000000000..3ccc05563 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/StatsDashes.kt @@ -0,0 +1,580 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.droiddeck.launcher.R +import com.droiddeck.launcher.session.GameStatusFiles +import com.droiddeck.launcher.session.GameTelemetry +import com.droiddeck.launcher.session.StatsDash +import com.droiddeck.launcher.session.Telemetry +import kotlin.math.abs +import kotlin.math.cos +import kotlin.math.floor +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt +import kotlin.math.sin + +private val DASH_H = 290.dp +/** Pedal and speed trace length: 150 readings at the page's 66 ms draw rate, about 10 s. */ +internal const val TRACE_N = 150 + +internal class TraceData(val throttle: List, val brake: List, val speed: List) + +/** The racing dash in [style]; a tap moves to the next one. */ +@Composable +internal fun RaceDashView(style: StatsDash, t: Telemetry, trace: TraceData, onTap: () -> Unit) { + Box(Modifier.fillMaxWidth().clickable(onClick = onTap)) { + when (style) { + StatsDash.AUTO, StatsDash.SPEEDOMETER -> SpeedometerDash(t) + StatsDash.CIRCUIT -> CircuitDash(t) + StatsDash.TRACE -> TraceDash(t, trace) + StatsDash.DIALS -> DialsDash(t) + StatsDash.GLANCE -> GlanceDash(t) + } + } +} + +private fun polar(cx: Float, cy: Float, r: Float, deg: Float): Offset { + val a = Math.toRadians((deg - 90f).toDouble()) + return Offset(cx + r * cos(a).toFloat(), cy + r * sin(a).toFloat()) +} + +private const val DIAL_START = -125f +private const val DIAL_SWEEP = 250f + +private fun dialAngle(v: Float, max: Float) = DIAL_START + DIAL_SWEEP * (v / max).coerceIn(0f, 1f) + +/** A 250° dial face: track, optional red zone from [red], ticks and numbers every [major]. */ +private fun DrawScope.dialFace( + cx: Float, cy: Float, r: Float, max: Float, major: Float, label: (Float) -> String, + measurer: TextMeasurer, labelStyle: TextStyle, tick: Color, minor: Color, track: Color, red: Float?, redColor: Color, +) { + val box = Size(2 * r, 2 * r) + val tl = Offset(cx - r, cy - r) + drawArc(track, DIAL_START - 90f, DIAL_SWEEP, false, topLeft = tl, size = box, style = Stroke(6.dp.toPx(), cap = StrokeCap.Round)) + if (red != null && red < max) { + drawArc(redColor, dialAngle(red, max) - 90f, dialAngle(max, max) - dialAngle(red, max), false, topLeft = tl, size = box, style = Stroke(6.dp.toPx())) + } + val steps = (max / (major / 2f)).roundToInt() + for (i in 0..steps) { + val v = i * major / 2f + val isMajor = i % 2 == 0 + val deg = dialAngle(v, max) + drawLine(if (isMajor) tick else minor, polar(cx, cy, r - (if (isMajor) 14.dp else 7.dp).toPx(), deg), polar(cx, cy, r - 2.dp.toPx(), deg), + strokeWidth = (if (isMajor) 2.5f else 1.2f).dp.toPx()) + if (isMajor) { + val layout = measurer.measure(label(v), labelStyle) + val p = polar(cx, cy, r - 27.dp.toPx(), deg) + drawText(layout, topLeft = Offset(p.x - layout.size.width / 2f, p.y - layout.size.height / 2f)) + } + } +} + +private fun DrawScope.needle(cx: Float, cy: Float, len: Float, deg: Float, color: Color, hub: Color) { + drawLine(color, Offset(cx, cy), polar(cx, cy, len, deg), strokeWidth = 4.dp.toPx(), cap = StrokeCap.Round) + drawCircle(hub, radius = 7.dp.toPx(), center = Offset(cx, cy)) +} + +private fun speedScale(speed: Float?): Float = if ((speed ?: 0f) > 210f) 320f else 220f + +private fun rpmScale(t: Telemetry): Float = max(t.maxRpm ?: 9000f, t.rpm ?: 0f) + +private fun kmh(t: Telemetry) = t.speedKmh?.let { "%.0f".format(it) } ?: "–" + +@Composable +private fun SpeedometerDash(t: Telemetry) { + val colors = MaterialTheme.colorScheme + val accent = LocalPalette.current.primary + Row(Modifier.fillMaxWidth().height(DASH_H), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + StatsCard(stringResource(R.string.stats_speed), Modifier.weight(1.55f).fillMaxHeight()) { + Box(Modifier.fillMaxWidth().weight(1f)) { + SpeedDial(t, Modifier.fillMaxSize()) + Column(Modifier.align(Alignment.BottomCenter), horizontalAlignment = Alignment.CenterHorizontally) { + Text(t.gear ?: "–", fontSize = 44.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace, color = accent) + Text("${kmh(t)} km/h", fontSize = 16.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace, color = colors.onBackground) + Text(t.rpm?.let { "%,.0f rpm".format(it) } ?: "", fontSize = 11.sp, fontFamily = FontFamily.Monospace, color = colors.onSurfaceVariant) + } + } + } + Column(Modifier.weight(1f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + StatsCard(stringResource(R.string.stats_pedals), Modifier.fillMaxWidth().weight(1f)) { Pedals(t, Modifier.fillMaxWidth().weight(1f)) } + if (t.gLat != null && t.gLon != null) { + StatsCard(stringResource(R.string.stats_gforce), Modifier.fillMaxWidth().weight(1f)) { GForce(t, Modifier.fillMaxWidth().weight(1f)) } + } + } + } +} + +@Composable +private fun SpeedDial(t: Telemetry, modifier: Modifier) { + val pal = LocalPalette.current + val colors = MaterialTheme.colorScheme + val measurer = rememberTextMeasurer() + val labelStyle = TextStyle(color = colors.onSurfaceVariant, fontSize = 11.sp, fontFamily = FontFamily.Monospace) + val vmax = speedScale(t.speedKmh) + val rpmFraction = ((t.rpm ?: 0f) / rpmScale(t)).coerceIn(0f, 1f) + Canvas(modifier) { + val r = min(size.width * 0.42f, size.height * 0.47f) + val cx = size.width / 2f + val cy = r + 12.dp.toPx() + dialFace(cx, cy, r, vmax, 20f, { "%.0f".format(it) }, measurer, labelStyle, colors.onBackground, colors.onSurfaceVariant, pal.line, null, StatsColors.brake) + val rr = r + 9.dp.toPx() + val box = Size(2 * rr, 2 * rr) + val tl = Offset(cx - rr, cy - rr) + drawArc(pal.line, DIAL_START - 90f, DIAL_SWEEP, false, topLeft = tl, size = box, style = Stroke(5.dp.toPx(), cap = StrokeCap.Round)) + if (rpmFraction > 0f) drawArc(if (rpmFraction > 0.9f) StatsColors.brake else pal.primary, DIAL_START - 90f, DIAL_SWEEP * rpmFraction, false, + topLeft = tl, size = box, style = Stroke(5.dp.toPx(), cap = StrokeCap.Round)) + needle(cx, cy, r - 16.dp.toPx(), dialAngle(t.speedKmh ?: 0f, vmax), colors.onBackground, pal.primary) + } +} + +@Composable +private fun Pedals(t: Telemetry, modifier: Modifier) { + Row(modifier, horizontalArrangement = Arrangement.SpaceEvenly) { + PedalBar("THR", t.throttle, StatsColors.throttle) + PedalBar("BRK", t.brake, StatsColors.brake) + } +} + +@Composable +private fun PedalBar(label: String, value: Float?, color: Color) { + val pal = LocalPalette.current + val colors = MaterialTheme.colorScheme + Column(Modifier.fillMaxHeight(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text(value?.let { "${(it * 100).roundToInt()}%" } ?: "–", fontSize = 11.sp, fontFamily = FontFamily.Monospace, color = colors.onBackground) + Box(Modifier.weight(1f).width(26.dp).clip(RoundedCornerShape(6.dp)).background(pal.line)) { + Box(Modifier.fillMaxWidth().fillMaxHeight((value ?: 0f).coerceIn(0f, 1f)).align(Alignment.BottomCenter).background(color)) + } + Text(label, fontSize = 11.sp, fontWeight = FontWeight.Bold, color = colors.onSurfaceVariant) + } +} + +@Composable +private fun GForce(t: Telemetry, modifier: Modifier) { + val pal = LocalPalette.current + val colors = MaterialTheme.colorScheme + val g = kotlin.math.hypot(t.gLat ?: 0f, t.gLon ?: 0f) + Box(modifier) { + Canvas(Modifier.fillMaxSize()) { + val steerH = if (t.steer != null) 10.dp.toPx() else 0f + val r = min(size.width / 2f, (size.height - steerH) / 2f) - 2.dp.toPx() + val cx = size.width / 2f + val cy = (size.height - steerH) / 2f + for (k in listOf(1f, 0.62f, 0.24f)) drawCircle(pal.line, radius = r * k, center = Offset(cx, cy), style = Stroke(1.dp.toPx())) + drawLine(pal.line, Offset(cx - r, cy), Offset(cx + r, cy), strokeWidth = 1.dp.toPx()) + drawLine(pal.line, Offset(cx, cy - r), Offset(cx, cy + r), strokeWidth = 1.dp.toPx()) + val gx = (t.gLat ?: 0f).coerceIn(-2f, 2f) / 2f * r + val gy = (t.gLon ?: 0f).coerceIn(-2f, 2f) / 2f * r + drawCircle(colors.onBackground, radius = 6.dp.toPx(), center = Offset(cx + gx, cy - gy)) + t.steer?.let { s -> + val y = size.height - 3.dp.toPx() + drawLine(pal.line, Offset(cx - r, y), Offset(cx + r, y), strokeWidth = 5.dp.toPx(), cap = StrokeCap.Round) + drawLine(pal.primary, Offset(cx, y), Offset(cx + s * r, y), strokeWidth = 5.dp.toPx(), cap = StrokeCap.Round) + } + } + Text("%.1f G".format(g), fontSize = 11.sp, fontFamily = FontFamily.Monospace, color = colors.onSurfaceVariant, modifier = Modifier.align(Alignment.TopEnd)) + } +} + +@Composable +private fun CircuitDash(t: Telemetry) { + val colors = MaterialTheme.colorScheme + val accent = LocalPalette.current.primary + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + StatsCard(null, Modifier.fillMaxWidth()) { ShiftLights(t, Modifier.fillMaxWidth().height(20.dp)) } + Row(Modifier.fillMaxWidth().height(DASH_H - 48.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + StatsCard(stringResource(R.string.stats_timing), Modifier.weight(1f).fillMaxHeight()) { + KvRow(stringResource(R.string.bottom_telemetry_lap), t.lap?.let { lap -> t.laps?.let { "$lap / $it" } ?: "$lap" } ?: "–") + KvRow(stringResource(R.string.bottom_telemetry_position), t.position?.let { "P$it" } ?: "–") + KvRow(stringResource(R.string.bottom_telemetry_this_lap), t.currentLapS?.let { lapTime(it) } ?: "–") + KvRow(stringResource(R.string.bottom_telemetry_last_lap), t.lastLapS?.let { lapTime(it) } ?: "–") + t.bestLapS?.let { KvRow(stringResource(R.string.stats_best_lap), lapTime(it), accent) } + } + StatsCard("", Modifier.weight(1.2f).fillMaxHeight()) { + Column(Modifier.fillMaxWidth().weight(1f), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { + Text(t.gear ?: "–", fontSize = 72.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace, color = accent) + Text("${kmh(t)} km/h", fontSize = 24.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace, color = colors.onBackground) + Text(t.rpm?.let { rpm -> t.maxRpm?.let { "%,.0f / %,.0f rpm".format(rpm, it) } ?: "%,.0f rpm".format(rpm) } ?: "", + fontSize = 12.sp, fontFamily = FontFamily.Monospace, color = colors.onSurfaceVariant) + } + } + Column(Modifier.weight(0.9f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + StatsCard(stringResource(R.string.bottom_telemetry_fuel), Modifier.fillMaxWidth().weight(1f)) { + val fuel = t.fuel + if (fuel != null) { + Bar(fuel, colors.onBackground, Modifier.fillMaxWidth().height(14.dp)) + Text("${(fuel * 100).roundToInt()}%", fontSize = 13.sp, fontFamily = FontFamily.Monospace, color = colors.onSurfaceVariant) + } else Text("–", fontSize = 13.sp, color = colors.onSurfaceVariant) + } + t.tireTempsC?.takeIf { it.size == 4 }?.let { tires -> + StatsCard(stringResource(R.string.stats_tires), Modifier.fillMaxWidth().weight(1.3f)) { Tires(tires, Modifier.fillMaxWidth().weight(1f)) } + } + } + } + } +} + +@Composable +private fun ShiftLights(t: Telemetry, modifier: Modifier) { + val pal = LocalPalette.current + val ratio = ((t.rpm ?: 0f) / rpmScale(t)).coerceIn(0f, 1f) + val lit = (((ratio - 0.6f) / 0.35f).coerceIn(0f, 1f) * 15).roundToInt() + val flash = ratio > 0.95f && (System.currentTimeMillis() / 120) % 2 == 0L + Canvas(modifier) { + val step = size.width / 15f + val r = min(size.height / 2f, step / 2f - 2.dp.toPx()) + for (i in 0 until 15) { + val c = when { + flash -> pal.primary + i >= lit -> pal.line + i < 5 -> StatsColors.shiftGreen + i < 10 -> StatsColors.warn + i < 13 -> StatsColors.brake + else -> pal.primary + } + drawCircle(c, radius = r, center = Offset(step * i + step / 2f, size.height / 2f)) + } + } +} + +@Composable +private fun KvRow(label: String, value: String, valueColor: Color = MaterialTheme.colorScheme.onBackground) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(label, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.weight(1f), maxLines = 1) + Text(value, fontSize = 15.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace, color = valueColor, maxLines = 1) + } +} + +@Composable +private fun Bar(fraction: Float, color: Color, modifier: Modifier) { + Box(modifier.clip(RoundedCornerShape(5.dp)).background(LocalPalette.current.line)) { + Box(Modifier.fillMaxWidth(fraction.coerceIn(0f, 1f)).fillMaxHeight().background(color)) + } +} + +@Composable +private fun Tires(tires: List, modifier: Modifier) { + Column(modifier, verticalArrangement = Arrangement.spacedBy(6.dp)) { + Row(Modifier.fillMaxWidth().weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + TireBox(tires[0], Modifier.weight(1f).fillMaxHeight()) + TireBox(tires[1], Modifier.weight(1f).fillMaxHeight()) + } + Row(Modifier.fillMaxWidth().weight(1f), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + TireBox(tires[2], Modifier.weight(1f).fillMaxHeight()) + TireBox(tires[3], Modifier.weight(1f).fillMaxHeight()) + } + } +} + +@Composable +private fun TireBox(c: Float, modifier: Modifier) { + val bg = when { + c > 100f -> Color(0xFF5A2A2C) + c < 70f -> Color(0xFF1D3550) + else -> Color(0xFF23352B) + } + Box(modifier.clip(RoundedCornerShape(6.dp)).background(bg), contentAlignment = Alignment.Center) { + Text("%.0f".format(c), fontSize = 13.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onBackground) + } +} + +@Composable +private fun TraceDash(t: Telemetry, trace: TraceData) { + val colors = MaterialTheme.colorScheme + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + StatsCard(stringResource(R.string.stats_trace), Modifier.fillMaxWidth().height(168.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) { + Legend(StatsColors.throttle, "${stringResource(R.string.stats_throttle)} ${t.throttle?.let { (it * 100).roundToInt() } ?: 0}%") + Legend(StatsColors.brake, "${stringResource(R.string.stats_brake)} ${t.brake?.let { (it * 100).roundToInt() } ?: 0}%") + } + TraceChart(listOf(trace.throttle to StatsColors.throttle, trace.brake to StatsColors.brake), 1f, Modifier.fillMaxWidth().weight(1f)) + } + val top = max(200f, (trace.speed.maxOrNull() ?: 0f) * 1.1f) + StatsCard(stringResource(R.string.stats_speed_chart), Modifier.fillMaxWidth().height(114.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) { + Legend(StatsColors.speed, "${stringResource(R.string.stats_speed)} ${kmh(t)}") + Text("0 – %.0f".format(top), fontSize = 11.sp, fontFamily = FontFamily.Monospace, color = colors.onSurfaceVariant) + } + TraceChart(listOf(trace.speed to StatsColors.speed), top, Modifier.fillMaxWidth().weight(1f)) + } + } +} + +@Composable +private fun Legend(color: Color, text: String) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) { + Box(Modifier.size(8.dp).clip(CircleShape).background(color)) + Text(text, fontSize = 12.sp, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onBackground, maxLines = 1) + } +} + +@Composable +private fun TraceChart(series: List, Color>>, max: Float, modifier: Modifier) { + val pal = LocalPalette.current + Canvas(modifier) { + for (k in listOf(0f, 0.5f, 1f)) { + val y = size.height - k * size.height + drawLine(pal.line, Offset(0f, y), Offset(size.width, y), strokeWidth = 1.dp.toPx()) + } + for ((values, color) in series) { + if (values.size < 2) continue + val path = Path() + val offset = TRACE_N - values.size + values.forEachIndexed { i, v -> + val x = (i + offset) / (TRACE_N - 1f) * size.width + val y = size.height - (v / max).coerceIn(0f, 1f) * size.height + if (i == 0) path.moveTo(x, y) else path.lineTo(x, y) + } + drawPath(path, color, style = Stroke(width = 2.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round)) + } + } +} + +@Composable +private fun DialsDash(t: Telemetry) { + val colors = MaterialTheme.colorScheme + val rpmMax = (rpmScale(t) / 1000f).let { kotlin.math.ceil(it) * 1000f } + val hasExtras = t.lights != null || t.turboBar != null || t.engineTempC != null || t.oilTempC != null + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(Modifier.fillMaxWidth().height(if (hasExtras) DASH_H - 56.dp else DASH_H), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + StatsCard("", Modifier.weight(1f).fillMaxHeight()) { + ArcDial(t.speedKmh ?: 0f, speedScale(t.speedKmh), 40f, { "%.0f".format(it) }, null, kmh(t), "km/h", Modifier.fillMaxWidth().weight(1f)) + } + StatsCard("", Modifier.weight(1f).fillMaxHeight()) { + ArcDial(t.rpm ?: 0f, rpmMax, 1000f, { "%.0f".format(it / 1000f) }, t.maxRpm?.let { it * 0.9f }, + t.rpm?.let { "%,.0f".format(it) } ?: "–", "rpm × 1000 · ${t.gear ?: "–"}", Modifier.fillMaxWidth().weight(1f)) + } + } + if (hasExtras) StatsCard(stringResource(R.string.stats_lights), Modifier.fillMaxWidth()) { + Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + t.lights?.let { on -> + Lamp(stringResource(R.string.stats_lamp_shift), on and GameTelemetry.DL_SHIFT != 0, LocalPalette.current.primary) + Lamp(stringResource(R.string.stats_lamp_handbrake), on and GameTelemetry.DL_HANDBRAKE != 0, StatsColors.brake) + Lamp(stringResource(R.string.stats_lamp_pit), on and GameTelemetry.DL_PITSPEED != 0, StatsColors.warn) + Lamp(stringResource(R.string.stats_lamp_tc), on and GameTelemetry.DL_TC != 0, StatsColors.warn) + Lamp(stringResource(R.string.stats_lamp_abs), on and GameTelemetry.DL_ABS != 0, StatsColors.warn) + } + t.turboBar?.let { Lamp(stringResource(R.string.stats_turbo, "%.1f".format(it)), false, colors.onBackground) } + t.engineTempC?.let { Lamp(stringResource(R.string.stats_engine_temp, "%.0f".format(it)), false, colors.onBackground) } + t.oilTempC?.let { Lamp(stringResource(R.string.stats_oil_temp, "%.0f".format(it)), false, colors.onBackground) } + } + } + } +} + +@Composable +private fun ArcDial(value: Float, max: Float, major: Float, label: (Float) -> String, red: Float?, readout: String, unit: String, modifier: Modifier) { + val pal = LocalPalette.current + val colors = MaterialTheme.colorScheme + val measurer = rememberTextMeasurer() + val labelStyle = TextStyle(color = colors.onSurfaceVariant, fontSize = 11.sp, fontFamily = FontFamily.Monospace) + Box(modifier) { + Canvas(Modifier.fillMaxSize()) { + val r = min(size.width * 0.46f, size.height * 0.5f) + val cx = size.width / 2f + val cy = r + 6.dp.toPx() + dialFace(cx, cy, r, max, major, label, measurer, labelStyle, colors.onBackground, colors.onSurfaceVariant, pal.line, red, StatsColors.brake) + needle(cx, cy, r - 18.dp.toPx(), dialAngle(value, max), colors.onBackground, pal.primary) + } + Column(Modifier.align(Alignment.BottomCenter), horizontalAlignment = Alignment.CenterHorizontally) { + Text(readout, fontSize = 22.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace, color = colors.onBackground) + Text(unit, fontSize = 11.sp, color = colors.onSurfaceVariant, maxLines = 1) + } + } +} + +@Composable +private fun Lamp(label: String, on: Boolean, color: Color) { + val pal = LocalPalette.current + val shape = RoundedCornerShape(8.dp) + Text(label, fontSize = 12.sp, fontFamily = FontFamily.Monospace, fontWeight = if (on) FontWeight.Bold else FontWeight.Normal, + color = if (on) MaterialTheme.colorScheme.background else MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, + modifier = Modifier.clip(shape).background(if (on) color else Color.Transparent).border(1.dp, if (on) color else pal.line, shape) + .padding(horizontal = 10.dp, vertical = 4.dp)) +} + +@Composable +private fun GlanceDash(t: Telemetry) { + val colors = MaterialTheme.colorScheme + val accent = LocalPalette.current.primary + val ratio = ((t.rpm ?: 0f) / rpmScale(t)).coerceIn(0f, 1f) + Row(Modifier.fillMaxWidth().height(DASH_H), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + EdgeBar(t.throttle, StatsColors.throttle) + StatsCard("", Modifier.weight(1f).fillMaxHeight()) { + Bar(ratio, if (ratio > 0.9f) StatsColors.brake else accent, Modifier.fillMaxWidth().height(14.dp)) + Column(Modifier.fillMaxWidth().weight(1f), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center) { + Text(t.gear ?: "–", fontSize = 110.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace, color = accent, lineHeight = 112.sp) + Text("${kmh(t)} km/h", fontSize = 34.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace, color = colors.onBackground) + } + } + EdgeBar(t.brake, StatsColors.brake) + } +} + +@Composable +private fun EdgeBar(value: Float?, color: Color) { + Box(Modifier.width(14.dp).fillMaxHeight().clip(RoundedCornerShape(5.dp)).background(LocalPalette.current.line)) { + Box(Modifier.fillMaxWidth().fillMaxHeight((value ?: 0f).coerceIn(0f, 1f)).align(Alignment.BottomCenter).background(color)) + } +} + +/** War Thunder's own labels the Flight layout places; others stay in the list. */ +internal object FlightLabels { + const val IAS = "IAS, km/h" + const val ALTITUDE = "H, m" + const val G = "Ny" + const val FUEL = "Mfuel, kg" + const val THROTTLE = "throttle 1, %" + + fun drawable(rows: List>): Boolean = rows.any { it.first == IAS || it.first == ALTITUDE } +} + +@Composable +internal fun FlightDash(rows: List>) { + val colors = MaterialTheme.colorScheme + val v = rows.toMap() + fun num(key: String) = v[key]?.toFloatOrNull() + Row(Modifier.fillMaxWidth().height(DASH_H), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + StatsCard(FlightLabels.IAS, Modifier.weight(1f).fillMaxHeight()) { + num(FlightLabels.IAS)?.let { Tape(it, 10f, 50f, Modifier.fillMaxWidth().weight(1f)) } ?: Text("–", color = colors.onSurfaceVariant) + } + Column(Modifier.weight(1.3f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + StatsCard(FlightLabels.THROTTLE, Modifier.fillMaxWidth().weight(1f)) { + val thr = num(FlightLabels.THROTTLE) + Bar((thr ?: 0f) / 110f, StatsColors.throttle, Modifier.fillMaxWidth().height(16.dp)) + Text(thr?.let { "%.0f%%".format(it) } ?: "–", fontSize = 13.sp, fontFamily = FontFamily.Monospace, color = colors.onSurfaceVariant) + } + StatsCard(FlightLabels.G, Modifier.fillMaxWidth().weight(1f)) { + Text(num(FlightLabels.G)?.let { "%.1f".format(it) } ?: "–", fontSize = 34.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace, + color = colors.onBackground) + num(FlightLabels.FUEL)?.let { Text("${FlightLabels.FUEL} %.0f".format(it), fontSize = 12.sp, color = colors.onSurfaceVariant) } + } + } + StatsCard(FlightLabels.ALTITUDE, Modifier.weight(1f).fillMaxHeight()) { + num(FlightLabels.ALTITUDE)?.let { Tape(it, 100f, 500f, Modifier.fillMaxWidth().weight(1f)) } ?: Text("–", color = colors.onSurfaceVariant) + } + } +} + +@Composable +private fun Tape(value: Float, step: Float, every: Float, modifier: Modifier) { + val pal = LocalPalette.current + val colors = MaterialTheme.colorScheme + val measurer = rememberTextMeasurer() + val small = TextStyle(color = colors.onSurfaceVariant, fontSize = 11.sp, fontFamily = FontFamily.Monospace) + val big = TextStyle(color = colors.onBackground, fontSize = 18.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace) + Canvas(modifier) { + val cy = size.height / 2f + val px = (size.height / 2f) / (step * 4f) + val boxH = 32.dp.toPx() + var m = floor((value - step * 5f) / step) * step + while (m < value + step * 6f) { + val y = cy - (m - value) * px + if (y in 0f..size.height) { + val labelled = abs((m / every).roundToInt() * every - m) < 0.01f + drawLine(colors.onSurfaceVariant, Offset(size.width * 0.74f, y), Offset(size.width, y), strokeWidth = (if (labelled) 2.5f else 1f).dp.toPx()) + if (labelled && abs(y - cy) > boxH * 0.75f) { + val layout = measurer.measure("%.0f".format(m), small) + drawText(layout, topLeft = Offset(size.width * 0.70f - layout.size.width, y - layout.size.height / 2f)) + } + } + m += step + } + drawRoundRect(pal.primary, topLeft = Offset(1.dp.toPx(), cy - boxH / 2f), size = Size(size.width - 2.dp.toPx(), boxH), + cornerRadius = CornerRadius(8.dp.toPx()), style = Stroke(2.dp.toPx())) + val layout = measurer.measure("%.0f".format(value), big) + drawText(layout, topLeft = Offset(size.width / 2f - layout.size.width / 2f, cy - layout.size.height / 2f)) + } +} + +@Composable +internal fun ShipDash(e: GameStatusFiles.Elite) { + val colors = MaterialTheme.colorScheme + Row(Modifier.fillMaxWidth().height(210.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + StatsCard(stringResource(R.string.stats_power), Modifier.weight(1.2f).fillMaxHeight()) { + e.pips?.takeIf { it.size == 3 }?.let { Pips(it, Modifier.fillMaxWidth().weight(1f)) } ?: Text("–", color = colors.onSurfaceVariant) + } + Column(Modifier.weight(1f).fillMaxHeight(), verticalArrangement = Arrangement.spacedBy(8.dp)) { + StatsCard(stringResource(R.string.stats_fuel_t), Modifier.fillMaxWidth().weight(1f)) { + e.fuelMain?.let { Text(stringResource(R.string.stats_fuel_main, "%.1f".format(it)), fontSize = 15.sp, fontWeight = FontWeight.Bold, + fontFamily = FontFamily.Monospace, color = colors.onBackground) } + e.fuelReserve?.let { Text(stringResource(R.string.stats_fuel_reserve, "%.2f".format(it)), fontSize = 12.sp, color = colors.onSurfaceVariant) } + } + StatsCard("", Modifier.fillMaxWidth().weight(1f)) { + e.cargo?.let { KvRow(stringResource(R.string.bottom_telemetry_cargo), "%.0f t".format(it)) } + e.legal?.let { KvRow(stringResource(R.string.bottom_telemetry_legal), it, if (it == "Clean") StatsColors.gpu else StatsColors.warn) } + e.balance?.let { KvRow(stringResource(R.string.bottom_telemetry_balance), "%,d CR".format(it)) } + } + } + } +} + +@Composable +private fun Pips(pips: List, modifier: Modifier) { + val pal = LocalPalette.current + val colors = MaterialTheme.colorScheme + val measurer = rememberTextMeasurer() + val style = TextStyle(color = colors.onSurfaceVariant, fontSize = 12.sp, fontWeight = FontWeight.Bold) + val names = listOf("SYS", "ENG", "WEP") + Canvas(modifier) { + val colW = size.width / 3f + val pipW = colW * 0.62f + val labelH = 18.dp.toPx() + val pipH = (size.height - labelH) / 4f - 4.dp.toPx() + for (col in 0 until 3) { + val x = col * colW + (colW - pipW) / 2f + // Status.json counts half pips: 0..8 per system. + val value = pips[col] / 2f + for (i in 0 until 4) { + val y = size.height - labelH - (i + 1) * (pipH + 4.dp.toPx()) + drawRoundRect(pal.line, topLeft = Offset(x, y), size = Size(pipW, pipH), cornerRadius = CornerRadius(4.dp.toPx())) + val fill = (value - i).coerceIn(0f, 1f) + if (fill > 0f) drawRoundRect(pal.primary, topLeft = Offset(x, y), size = Size(pipW * fill, pipH), cornerRadius = CornerRadius(4.dp.toPx())) + } + val layout = measurer.measure(names[col], style) + drawText(layout, topLeft = Offset(col * colW + colW / 2f - layout.size.width / 2f, size.height - layout.size.height)) + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StatsStrips.kt b/app/src/main/java/com/droiddeck/launcher/ui/StatsStrips.kt new file mode 100644 index 000000000..e67120da7 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/StatsStrips.kt @@ -0,0 +1,288 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.droiddeck.launcher.R +import com.droiddeck.launcher.hud.DroidDeckMark +import com.droiddeck.launcher.hud.HudMetrics +import com.droiddeck.launcher.session.StatsStrip +import kotlin.math.max +import kotlin.math.roundToInt + +/** FusionHUD's metric colors, so the bottom screen reads like the HUD on the top screen. */ +internal object StatsColors { + val cpu = Color(0xFF58A6FF) + val gpu = Color(0xFF5EE08A) + val ram = Color(0xFFFF7BC0) + val bat = Color(0xFFFFAB5E) + // Throttle and brake stay apart for red-green colour blindness (checked against the dark surface). + val throttle = Color(0xFF14A39A) + val brake = Color(0xFFE5484D) + val speed = Color(0xFF2E8FD6) + val warn = Color(0xFFF2C94C) + val shiftGreen = Color(0xFF2FB36A) + val badge = Color(0xFF0B0D10) +} + +/** The last [capacity] readings, oldest first. */ +internal class History(private val capacity: Int) { + private val values = ArrayDeque(capacity) + fun add(v: Float) { + if (values.size == capacity) values.removeFirst() + values.addLast(v) + } + fun snapshot(): List = values.toList() +} + +private val MARK_RATIO = DroidDeckMark.widthFor(1f) + +/** The DroidDeck mark (the split D with the blue orb), the same artwork the HUD draws. */ +@Composable +internal fun DroidDeckLogo(height: Dp, modifier: Modifier = Modifier, body: Color = Color.White, orb: Color = LocalPalette.current.primary) { + Canvas(modifier.size(height * MARK_RATIO, height)) { + val b = body.toArgb() + val o = orb.toArgb() + drawIntoCanvas { DroidDeckMark.draw(it.nativeCanvas, 0f, 0f, size.height, b, o) } + } +} + +@Composable +private fun LogoBadge(size: Dp) { + val shape = RoundedCornerShape(size * 0.22f) + Box(Modifier.size(size).clip(shape).background(StatsColors.badge).border(2.dp, LocalPalette.current.primary, shape), contentAlignment = Alignment.Center) { + DroidDeckLogo(size * 0.62f) + } +} + +@Composable +private fun LogoHeader(clock: String) { + val colors = MaterialTheme.colorScheme + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.padding(horizontal = 2.dp)) { + DroidDeckLogo(16.dp) + Text("DroidDeck", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = colors.onBackground) + if (clock.isNotEmpty()) Text("· $clock", fontSize = 12.sp, fontFamily = FontFamily.Monospace, color = colors.onSurfaceVariant) + } +} + +@Composable +internal fun Sparkline(values: List, min: Float, max: Float, color: Color, modifier: Modifier) { + Canvas(modifier) { + if (values.size < 2 || max <= min) return@Canvas + val path = Path() + values.forEachIndexed { i, v -> + val x = i / (values.size - 1f) * size.width + val y = size.height - ((v - min) / (max - min)).coerceIn(0f, 1f) * (size.height - 2f) - 1f + if (i == 0) path.moveTo(x, y) else path.lineTo(x, y) + } + drawPath(path, color, style = Stroke(width = 2.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round)) + } +} + +/** A readout card: dark surface, a thin accent edge and the DroidDeck mark before the title ("" = mark only). */ +@Composable +internal fun StatsCard(title: String?, modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) { + val pal = LocalPalette.current + val shape = RoundedCornerShape(14.dp) + Column( + modifier.clip(shape).background(pal.surface).border(1.dp, pal.primary.copy(alpha = 0.22f), shape).padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + if (title != null) Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + DroidDeckLogo(12.dp, body = MaterialTheme.colorScheme.onSurfaceVariant) + if (title.isNotEmpty()) Text(title.uppercase(), fontSize = 11.sp, fontWeight = FontWeight.SemiBold, color = pal.primary, letterSpacing = 0.6.sp, + maxLines = 1, overflow = TextOverflow.Ellipsis) + } + content() + } +} + +/** What the top strip shows; histories hold one reading a second, oldest first. */ +internal class StripData( + val fps: Float?, + val device: HudMetrics.Snapshot?, + val fpsHistory: List, + val cpuHistory: List, + val gpuHistory: List, + val wattsHistory: List, + val clock: String, +) + +private fun percentTemp(percent: Int?, temp: Int?): String = + listOfNotNull(percent?.let { "$it%" }, temp?.let { "$it°" }).joinToString(" · ").ifEmpty { "–" } + +private fun batteryText(s: HudMetrics.Snapshot?): String = + listOfNotNull(s?.battery?.percent?.let { "$it%" }, s?.battery?.watts?.takeIf { it > 0f }?.let { "%.1f W".format(it) }).joinToString(" · ").ifEmpty { "–" } + +private fun fpsText(fps: Float?): String = fps?.takeIf { it > 0f }?.let { "%.1f".format(it) } ?: "–" + +/** The top strip; a tap moves to the next style. */ +@Composable +internal fun StatsStripView(style: StatsStrip, d: StripData, onTap: () -> Unit) { + Box(Modifier.fillMaxWidth().clickable(onClick = onTap)) { + when (style) { + StatsStrip.PANEL -> PanelStrip(d) + StatsStrip.TILES -> TilesStrip(d) + StatsStrip.RINGS -> RingsStrip(d) + } + } +} + +@Composable +private fun PanelStrip(d: StripData) { + val pal = LocalPalette.current + val colors = MaterialTheme.colorScheme + val accent = pal.primary + val shape = RoundedCornerShape(18.dp) + Row( + Modifier.fillMaxWidth().clip(shape).background(pal.surface).border(2.dp, accent, shape).padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + LogoBadge(40.dp) + Column(Modifier.widthIn(min = 84.dp)) { + Row(verticalAlignment = Alignment.Bottom) { + Text(fpsText(d.fps), fontSize = 30.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace, color = accent, maxLines = 1) + Text(" fps", fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(bottom = 4.dp)) + } + Text(d.fps?.takeIf { it > 0f }?.let { "%.1f ms".format(1000f / it) } ?: stringResource(R.string.stats_fps_needs_hud), + fontSize = 12.sp, fontFamily = FontFamily.Monospace, color = colors.onSurfaceVariant, maxLines = 1) + } + Sparkline(d.fpsHistory.map { 1000f / max(it, 1f) }, 8f, 34f, lerp(accent, Color.White, 0.3f), Modifier.weight(1f).height(44.dp)) + Column(verticalArrangement = Arrangement.spacedBy(1.dp)) { + MetricRow("CPU", percentTemp(d.device?.cpuPercent, d.device?.cpuTempC), StatsColors.cpu) + MetricRow("GPU", percentTemp(d.device?.gpuPercent, d.device?.gpuTempC), StatsColors.gpu) + MetricRow("RAM", d.device?.let { "${it.ramPercent.roundToInt()}%" } ?: "–", StatsColors.ram) + MetricRow("BAT", batteryText(d.device), StatsColors.bat) + } + } +} + +@Composable +private fun MetricRow(label: String, value: String, color: Color) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(label, fontSize = 11.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace, color = color, modifier = Modifier.width(36.dp)) + Text(value, fontSize = 13.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace, color = MaterialTheme.colorScheme.onBackground, maxLines = 1) + } +} + +@Composable +private fun TilesStrip(d: StripData) { + val accent = LocalPalette.current.primary + val s = d.device + val gpuPercent: Int? = s?.gpuPercent + val gpuTemp: Int? = s?.gpuTempC + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) { + LogoHeader(d.clock) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + HistoryTile("FPS", accent, fpsText(d.fps), "fps", d.fpsHistory, 75f) + HistoryTile("CPU", StatsColors.cpu, s?.cpuPercent?.let { "$it%" } ?: "–", s?.cpuTempC?.let { "$it°" } ?: "", d.cpuHistory, 100f) + HistoryTile("GPU", StatsColors.gpu, gpuPercent?.let { "$it%" } ?: gpuTemp?.let { "$it°" } ?: "–", + if (gpuPercent != null) gpuTemp?.let { "$it°" } ?: "" else "", d.gpuHistory, 100f) + HistoryTile(stringResource(R.string.bottom_telemetry_battery).uppercase(), StatsColors.bat, s?.battery?.percent?.let { "$it%" } ?: "–", + s?.battery?.watts?.takeIf { it > 0f }?.let { "%.1fW".format(it) } ?: "", d.wattsHistory, 10f) + } + } +} + +@Composable +private fun RowScope.HistoryTile(label: String, color: Color, value: String, unit: String, history: List, max: Float) { + val pal = LocalPalette.current + val colors = MaterialTheme.colorScheme + Column( + Modifier.weight(1f).clip(RoundedCornerShape(12.dp)).background(pal.surface) + .drawBehind { drawRect(color, topLeft = Offset.Zero, size = Size(size.width, 3.dp.toPx())) } + .padding(start = 10.dp, end = 10.dp, top = 9.dp, bottom = 6.dp), + ) { + Text(label, fontSize = 11.sp, fontWeight = FontWeight.Bold, color = color, letterSpacing = 0.8.sp, maxLines = 1) + Row(verticalAlignment = Alignment.Bottom) { + Text(value, fontSize = 22.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace, color = colors.onBackground, maxLines = 1) + if (unit.isNotEmpty()) Text(" $unit", fontSize = 11.sp, color = colors.onSurfaceVariant, maxLines = 1, modifier = Modifier.padding(bottom = 3.dp)) + } + Sparkline(history, 0f, max, color, Modifier.fillMaxWidth().height(24.dp)) + } +} + +@Composable +private fun RingsStrip(d: StripData) { + val s = d.device + val gpuPercent: Int? = s?.gpuPercent + val gpuTemp: Int? = s?.gpuTempC + Column(Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(6.dp)) { + LogoHeader(d.clock) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + RingTile("CPU", StatsColors.cpu, s?.cpuPercent?.let { it / 100f }, s?.cpuPercent?.let { "$it%" } ?: "–", s?.cpuTempC?.let { "$it °C" } ?: "") + RingTile("GPU", StatsColors.gpu, gpuPercent?.let { it / 100f }, gpuPercent?.let { "$it%" } ?: gpuTemp?.let { "$it°" } ?: "–", + if (gpuPercent != null) gpuTemp?.let { "$it °C" } ?: "" else "") + RingTile("RAM", StatsColors.ram, s?.ramPercent?.let { it / 100f }, s?.let { "${it.ramPercent.roundToInt()}%" } ?: "–", + s?.let { "%.1f of %.1f GB".format(it.ramUsedBytes / 1e9, it.ramTotalBytes / 1e9) } ?: "") + RingTile(stringResource(R.string.bottom_telemetry_battery).uppercase(), StatsColors.bat, s?.battery?.percent?.let { it / 100f }, + s?.battery?.percent?.let { "$it%" } ?: "–", s?.battery?.watts?.takeIf { it > 0f }?.let { "%.1f W".format(it) } ?: "") + } + } +} + +@Composable +private fun RowScope.RingTile(label: String, color: Color, fraction: Float?, center: String, sub: String) { + val pal = LocalPalette.current + val colors = MaterialTheme.colorScheme + Row( + Modifier.weight(1f).clip(RoundedCornerShape(12.dp)).background(pal.surface).padding(8.dp), + verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box(Modifier.size(52.dp), contentAlignment = Alignment.Center) { + Canvas(Modifier.fillMaxSize()) { + val w = 6.dp.toPx() + val tl = Offset(w / 2, w / 2) + val sz = Size(size.width - w, size.height - w) + drawArc(pal.line, 0f, 360f, false, topLeft = tl, size = sz, style = Stroke(width = w)) + if (fraction != null) drawArc(color, -90f, (fraction.coerceIn(0f, 1f) * 360f).coerceAtLeast(1f), false, topLeft = tl, size = sz, + style = Stroke(width = w, cap = StrokeCap.Round)) + } + Text(center, fontSize = 12.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace, color = colors.onBackground, maxLines = 1) + } + Column { + Text(label, fontSize = 11.sp, fontWeight = FontWeight.Bold, color = color, letterSpacing = 0.8.sp, maxLines = 1) + if (sub.isNotEmpty()) Text(sub, fontSize = 11.sp, color = colors.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c6bd42901..8c82992f9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1891,6 +1891,43 @@ %1$d notifications Clear Screenshots + FPS needs the HUD on + Speed + Pedals + G-force + Timing + Best lap + Tires °C + Throttle and brake, last 10 s + Speed, km/h + Throttle + Brake + Dashboard + Shift + Handbrake + Pit limiter + TC + ABS + Turbo %1$s bar + Engine %1$s °C + Oil %1$s °C + Power distributor + Fuel, t + Main %1$s t + Reserve %1$s t + Layout + Top + Dash + Tapping the top strip or the dash also switches to the next style. + Panel + Tiles + Rings + Auto + Speedometer + Circuit + Trace + Dials + Glance Send dashboard data Automatic Turns it on for every game listed here, including games found later. Games you switch off stay off. diff --git a/app/src/test/java/com/droiddeck/launcher/session/GameTelemetryTest.kt b/app/src/test/java/com/droiddeck/launcher/session/GameTelemetryTest.kt index 96e468e3a..fc5cbfca9 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/GameTelemetryTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/GameTelemetryTest.kt @@ -123,6 +123,71 @@ class GameTelemetryTest { assertNull(GameTelemetry.parse(GameTelemetry.OUTGAUGE_PORT, ByteArray(40), 40)) assertNull(GameTelemetry.parse(1234, ByteArray(400), 400)) } + + // DiRT Showdown's 38 floats already carry steering and both G-forces. + @Test fun codemastersGForceAndSteering() { + val data = packet(152) { + putFloat(30 * 4, -0.5f) + putFloat(33 * 4, 2f) + putFloat(34 * 4, 1.2f) + putFloat(35 * 4, -0.8f) + } + val t = GameTelemetry.parse(GameTelemetry.CODEMASTERS_PORT, data, data.size)!! + assertEquals(-0.5f, t.steer!!, 1e-4f) + assertEquals(1.2f, t.gLat!!, 1e-4f) + assertEquals(-0.8f, t.gLon!!, 1e-4f) + } + + @Test fun forzaReadsBestLapSteeringGForceAndTires() { + val dash = 232 + val data = packet(311) { + putInt(0, 1) + putFloat(20, 9.81f) + putFloat(28, -4.905f) + putFloat(dash + 24, 212f) + putFloat(dash + 28, 32f) + putFloat(dash + 32, 194f) + putFloat(dash + 36, 176f) + putFloat(dash + 52, 81.5f) + put(dash + 75, 3) + put(dash + 76, (-127).toByte()) + } + val t = GameTelemetry.parse(GameTelemetry.FORZA_PORT, data, data.size)!! + assertEquals(1f, t.gLat!!, 1e-3f) + assertEquals(-0.5f, t.gLon!!, 1e-3f) + assertEquals(listOf(100f, 0f, 90f, 80f), t.tireTempsC!!.map { Math.round(it).toFloat() }) + assertEquals(81.5f, t.bestLapS!!, 1e-4f) + assertEquals(-1f, t.steer!!, 1e-4f) + } + + @Test fun forzaWithoutTireDataLeavesThemOut() { + val data = packet(311) { putInt(0, 1); put(232 + 75, 2) } + assertNull(GameTelemetry.parse(GameTelemetry.FORZA_PORT, data, data.size)!!.tireTempsC) + } + + @Test fun outGaugeReadsTurboTemperaturesAndLights() { + val data = packet(92) { + put(10, 3) + putFloat(20, 0.8f) + putFloat(24, 92f) + putFloat(36, 104f) + putInt(40, GameTelemetry.DL_SHIFT or GameTelemetry.DL_ABS or GameTelemetry.DL_HANDBRAKE) + putInt(44, GameTelemetry.DL_SHIFT or GameTelemetry.DL_ABS) + } + val t = GameTelemetry.parse(GameTelemetry.OUTGAUGE_PORT, data, data.size)!! + assertEquals(0.8f, t.turboBar!!, 1e-4f) + assertEquals(92f, t.engineTempC!!, 1e-4f) + assertEquals(104f, t.oilTempC!!, 1e-4f) + assertEquals(GameTelemetry.DL_SHIFT or GameTelemetry.DL_ABS, t.lights) + } + + // BeamNG leaves some OutGauge fields at zero; those stay hidden. + @Test fun outGaugeZeroFieldsStayHidden() { + val t = GameTelemetry.parse(GameTelemetry.OUTGAUGE_PORT, packet(92) { put(10, 2) }, 92)!! + assertNull(t.turboBar) + assertNull(t.engineTempC) + assertNull(t.lights) + } } @RunWith(RobolectricTestRunner::class) @@ -134,6 +199,18 @@ class GameStatusFilesTest { assertEquals(listOf("H, m" to "1520", "TAS, km/h" to "512.4"), rows) } + @Test fun warThunderKeepsEveryNumberForTheFlightLayout() { + val json = buildString { + append("{\"valid\": true") + for (i in 0 until 20) append(", \"k$i\": $i") + append(", \"IAS, km/h\": 512, \"throttle 1, %\": 110}") + } + val rows = GameStatusFiles.warThunder(json) + assertEquals(22, rows.size) + assertTrue(com.droiddeck.launcher.ui.FlightLabels.drawable(rows)) + assertEquals("110", rows.toMap()["throttle 1, %"]) + } + @Test fun eliteAtTheMenusShowsNothing() { assertNull(GameStatusFiles.elite("""{"timestamp": "2026-10-31T02:00:00Z", "event": "Status", "Flags": 0}""")) } diff --git a/app/src/test/java/com/droiddeck/launcher/session/StatsLayoutTest.kt b/app/src/test/java/com/droiddeck/launcher/session/StatsLayoutTest.kt new file mode 100644 index 000000000..fa50f1472 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/session/StatsLayoutTest.kt @@ -0,0 +1,32 @@ +package com.droiddeck.launcher.session + +import org.junit.Assert.assertEquals +import org.junit.Test + +class StatsLayoutTest { + @Test fun unknownIdsFallBackToTheDefaults() { + assertEquals(StatsStrip.PANEL, StatsStrip.from(null)) + assertEquals(StatsStrip.RINGS, StatsStrip.from("rings")) + assertEquals(StatsDash.AUTO, StatsDash.from("gone")) + assertEquals(StatsDash.TRACE, StatsDash.from("trace")) + } + + @Test fun tappingTheStripCyclesThroughEveryStyle() { + assertEquals(StatsStrip.TILES, StatsStrip.PANEL.next()) + assertEquals(StatsStrip.RINGS, StatsStrip.TILES.next()) + assertEquals(StatsStrip.PANEL, StatsStrip.RINGS.next()) + } + + @Test fun autoPicksTheDashByTheGamesFormat() { + assertEquals(StatsDash.SPEEDOMETER, StatsDash.AUTO.resolve("Codemasters")) + assertEquals(StatsDash.CIRCUIT, StatsDash.AUTO.resolve("Forza")) + assertEquals(StatsDash.DIALS, StatsDash.AUTO.resolve("OutGauge")) + assertEquals(StatsDash.TRACE, StatsDash.TRACE.resolve("Forza")) + } + + @Test fun tappingTheDashMovesOnFromTheOneOnScreen() { + assertEquals(StatsDash.CIRCUIT, StatsDash.tapped(StatsDash.SPEEDOMETER)) + assertEquals(StatsDash.SPEEDOMETER, StatsDash.tapped(StatsDash.GLANCE)) + assertEquals(StatsDash.SPEEDOMETER, StatsDash.tapped(StatsDash.AUTO)) + } +}