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)) + } +}