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.
This commit is contained in:
The412Banner committed 2026-10-31 19:49:47 +00:00
1 parent 63ad5a722f
commit 5be04466af
10 files changed
+1201 -70

No files matched your search

@@ -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<Float>? = 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<Pair<String, String>> {
@@ -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<String, java.io.File>): java.io.File? {
@@ -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)
@@ -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(
@@ -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)]
}
}
@@ -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<Set<Int>>(emptySet()) }
val traffic = remember { GameTelemetry.PORTS.associateWith { PortTraffic() } }
var unknown by remember { mutableStateOf<List<UnknownTraffic>>(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 <T> PickRow(title: String, options: List<Pair<T, String>>, 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))
}
}
@@ -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<Float>, val brake: List<Float>, val speed: List<Float>)
/** 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<Float>, 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<Pair<List<Float>, 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<Pair<String, String>>): Boolean = rows.any { it.first == IAS || it.first == ALTITUDE }
}
@Composable
internal fun FlightDash(rows: List<Pair<String, String>>) {
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<Int>, 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))
}
}
}
@@ -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<Float>(capacity)
fun add(v: Float) {
if (values.size == capacity) values.removeFirst()
values.addLast(v)
}
fun snapshot(): List<Float> = 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<Float>, 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<Float>,
val cpuHistory: List<Float>,
val gpuHistory: List<Float>,
val wattsHistory: List<Float>,
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<Float>, 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)
}
}
}
+37
View File
@@ -1891,6 +1891,43 @@
<string name="bottom_notifications_count">%1$d notifications</string>
<string name="bottom_notifications_clear">Clear</string>
<string name="bottom_page_screenshots">Screenshots</string>
<string name="stats_fps_needs_hud">FPS needs the HUD on</string>
<string name="stats_speed">Speed</string>
<string name="stats_pedals">Pedals</string>
<string name="stats_gforce">G-force</string>
<string name="stats_timing">Timing</string>
<string name="stats_best_lap">Best lap</string>
<string name="stats_tires">Tires °C</string>
<string name="stats_trace">Throttle and brake, last 10 s</string>
<string name="stats_speed_chart">Speed, km/h</string>
<string name="stats_throttle">Throttle</string>
<string name="stats_brake">Brake</string>
<string name="stats_lights">Dashboard</string>
<string name="stats_lamp_shift">Shift</string>
<string name="stats_lamp_handbrake">Handbrake</string>
<string name="stats_lamp_pit">Pit limiter</string>
<string name="stats_lamp_tc">TC</string>
<string name="stats_lamp_abs">ABS</string>
<string name="stats_turbo">Turbo %1$s bar</string>
<string name="stats_engine_temp">Engine %1$s °C</string>
<string name="stats_oil_temp">Oil %1$s °C</string>
<string name="stats_power">Power distributor</string>
<string name="stats_fuel_t">Fuel, t</string>
<string name="stats_fuel_main">Main %1$s t</string>
<string name="stats_fuel_reserve">Reserve %1$s t</string>
<string name="stats_layout">Layout</string>
<string name="stats_layout_top">Top</string>
<string name="stats_layout_dash">Dash</string>
<string name="stats_layout_hint">Tapping the top strip or the dash also switches to the next style.</string>
<string name="stats_strip_panel">Panel</string>
<string name="stats_strip_tiles">Tiles</string>
<string name="stats_strip_rings">Rings</string>
<string name="stats_dash_auto">Auto</string>
<string name="stats_dash_speedometer">Speedometer</string>
<string name="stats_dash_circuit">Circuit</string>
<string name="stats_dash_trace">Trace</string>
<string name="stats_dash_dials">Dials</string>
<string name="stats_dash_glance">Glance</string>
<string name="bottom_telemetry_send_title">Send dashboard data</string>
<string name="bottom_telemetry_send_auto">Automatic</string>
<string name="bottom_telemetry_send_auto_hint">Turns it on for every game listed here, including games found later. Games you switch off stay off.</string>
@@ -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}"""))
}
@@ -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))
}
}