From e1d241d723014e5cc3cc0fb126d873cfc3279db0 Mon Sep 17 00:00:00 2001 From: The412Banner <205237651+The412Banner@users.noreply.github.com> Date: Sun, 1 Nov 2026 19:23:31 -0500 Subject: [PATCH] feat(dual-screen): bottom-screen HUD, Line strip, strip under every bottom tab, Bottom screen HUD settings (#1982) - In-game stats shows a bottom-screen HUD while no game sends dashboard data: 11 styles, tap to change; it reads the game's frames and the device itself, so it works with the top-screen HUD off. - Line strip: one line like the top HUD's Strip (API + FPS, frame-time graph, 1 / 0.1 / 0.01 % lows, GPU, CPU with temperature, VRAM, RAM, clock); default strip above a game's dashboard; numbers that do not fit are left out by importance. - The same strip under the tabs on every other bottom page during a session. - Bottom screen HUD settings, shown even with the top HUD off: one switch for the whole bottom HUD, strip on every tab, and which numbers the Line strip shows. - The bottom HUD redraws about 10 times a second (was about 3) and reads device stats twice a second. - GPU load, clock and VRAM readers also run while the bottom HUD shows them (BottomHudPresence). - Night style stays dim instead of flickering with the FPS. - Review fixes: slow-frame ring 2048, tab strip redraw without gamescope frames, Proton name per game, Graphs' duration from the kept readings, Fahrenheit in Tiles, Rings and Cores. --- .../com/droiddeck/launcher/SessionActivity.kt | 3 + .../launcher/hud/BottomHudPresence.kt | 15 + .../com/droiddeck/launcher/hud/GameFrames.kt | 25 + .../launcher/session/GpuMemComponent.kt | 3 +- .../launcher/session/GpuStatsComponent.kt | 3 +- .../launcher/session/HwmonComponent.kt | 4 + .../launcher/session/SessionPrefs.kt | 25 + .../launcher/session/SettingsReset.kt | 2 +- .../droiddeck/launcher/session/StatsLayout.kt | 30 +- .../droiddeck/launcher/ui/BottomDashboard.kt | 2 + .../com/droiddeck/launcher/ui/BottomPages.kt | 4 +- .../com/droiddeck/launcher/ui/BottomStrip.kt | 85 +++ .../launcher/ui/BottomTelemetryPage.kt | 296 +++++--- .../launcher/ui/DroidDeckHudSettings.kt | 49 ++ .../com/droiddeck/launcher/ui/StatsHud.kt | 667 ++++++++++++++++++ .../com/droiddeck/launcher/ui/StatsStrips.kt | 133 +++- .../com/droiddeck/launcher/ui/StripFit.kt | 15 + app/src/main/res/values/strings.xml | 23 +- .../droiddeck/launcher/hud/FrameStatsTest.kt | 24 + .../launcher/session/StatsLayoutTest.kt | 18 +- .../com/droiddeck/launcher/ui/HistoryTest.kt | 33 + .../com/droiddeck/launcher/ui/StripFitTest.kt | 26 + 22 files changed, 1393 insertions(+), 92 deletions(-) create mode 100644 app/src/main/java/com/droiddeck/launcher/hud/BottomHudPresence.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/BottomStrip.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/StatsHud.kt create mode 100644 app/src/main/java/com/droiddeck/launcher/ui/StripFit.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/ui/HistoryTest.kt create mode 100644 app/src/test/java/com/droiddeck/launcher/ui/StripFitTest.kt diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index d3b018dc0..1aa82d33c 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -3131,6 +3131,9 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { onPage = { com.droiddeck.launcher.ui.SessionBottomTab.pick(SessionState.sessionId, it) }, onClose = { selectSecondScreenMode(SecondScreenMode.NONE) }, onMenu = if (dockDrawer()) ({ drawerOpen = true }) else null, + strip = { pageId -> + com.droiddeck.launcher.ui.SessionBottomStrip(pageId) { if (::hud.isInitialized) hud.liveFps else null } + }, ) } }), diff --git a/app/src/main/java/com/droiddeck/launcher/hud/BottomHudPresence.kt b/app/src/main/java/com/droiddeck/launcher/hud/BottomHudPresence.kt new file mode 100644 index 000000000..7df9f10fd --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/hud/BottomHudPresence.kt @@ -0,0 +1,15 @@ +package com.droiddeck.launcher.hud + +import android.os.SystemClock + +internal object BottomHudPresence { + private const val STALE_MS = 2_500L + + @Volatile private var seenAt = 0L + + fun beat() { + seenAt = SystemClock.elapsedRealtime() + } + + val showing: Boolean get() = seenAt != 0L && SystemClock.elapsedRealtime() - seenAt < STALE_MS +} diff --git a/app/src/main/java/com/droiddeck/launcher/hud/GameFrames.kt b/app/src/main/java/com/droiddeck/launcher/hud/GameFrames.kt index 2bc92b98d..98dc35074 100644 --- a/app/src/main/java/com/droiddeck/launcher/hud/GameFrames.kt +++ b/app/src/main/java/com/droiddeck/launcher/hud/GameFrames.kt @@ -19,6 +19,10 @@ internal class FrameStats { private var sessionStartNs = 0L private var sessionFrames = 0L + private val slowTimes = LongArray(SLOW_FRAMES) + private var slowHead = 0 + private var slowSize = 0 + @Volatile var fps = 0f private set @@ -51,6 +55,11 @@ internal class FrameStats { if (lowsSize < LOWS_FRAMES) lowsSize++ if (sessionStartNs == 0L) sessionStartNs = timeNs - frametimeNs sessionFrames++ + if (frametimeNs > SLOW_NS) { + slowTimes[slowHead] = timeNs + slowHead = (slowHead + 1) % SLOW_FRAMES + if (slowSize < SLOW_FRAMES) slowSize++ + } } count++ } @@ -68,6 +77,8 @@ internal class FrameStats { cachedLowsNs = 0L sessionStartNs = 0L sessionFrames = 0L + slowHead = 0 + slowSize = 0 fps = 0f count++ } @@ -94,6 +105,16 @@ internal class FrameStats { @Synchronized fun sessionSeconds(nowNs: Long): Float = if (sessionStartNs == 0L) 0f else (nowNs - sessionStartNs) / 1e9f + @Synchronized + fun slowFrames(nowNs: Long, windowNs: Long): Int { + var n = 0 + for (i in 0 until slowSize) { + if (nowNs - slowTimes[(slowHead - 1 - i + SLOW_FRAMES) % SLOW_FRAMES] > windowNs) break + n++ + } + return n + } + class Lows(val low1Fps: Float, val low01Fps: Float, val low001Fps: Float) { companion object { val EMPTY = Lows(0f, 0f, 0f) @@ -128,6 +149,8 @@ internal class FrameStats { const val FPS_PERIOD_NS = 500_000_000L const val LOWS_FRAMES = 8192 private const val PAUSE_NS = 1_000_000_000L + const val SLOW_NS = 33_333_334L + private const val SLOW_FRAMES = 2048 private const val LOWS_RECOMPUTE_NS = 250_000_000L } } @@ -158,6 +181,8 @@ internal class GameFrameFeed(private val file: File, val stats: FrameStats = Fra @Volatile var refreshMilliHz = 0 private set + val live: Boolean get() = fromGamescope + fun poll(nowNs: Long): Boolean { val compositorAdded = compositorFrames != seenCompositorFrames seenCompositorFrames = compositorFrames diff --git a/app/src/main/java/com/droiddeck/launcher/session/GpuMemComponent.kt b/app/src/main/java/com/droiddeck/launcher/session/GpuMemComponent.kt index 93214c3d1..a9d55199d 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/GpuMemComponent.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/GpuMemComponent.kt @@ -21,7 +21,8 @@ class GpuMemComponent(private val file: File, private val root: File) : SessionP thread = Thread({ while (running) { try { Thread.sleep(PERIOD_MS) } catch (e: InterruptedException) { break } - if (running && (OverlayShown.check(root) || com.droiddeck.launcher.hud.DroidDeckHudHost.showing)) sample()?.let { write(it) } + if (running && (OverlayShown.check(root) || com.droiddeck.launcher.hud.DroidDeckHudHost.showing || + com.droiddeck.launcher.hud.BottomHudPresence.showing)) sample()?.let { write(it) } } }, "gpu-mem").apply { isDaemon = true; start() } } diff --git a/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt b/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt index 019d3b3df..8d2a3801d 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/GpuStatsComponent.kt @@ -133,7 +133,8 @@ class GpuStatsComponent(val dir: File, private val root: File) : SessionPart() { sources = "" } - private fun hudShown(): Boolean = OverlayShown.check(root) || com.droiddeck.launcher.hud.DroidDeckHudHost.showing + private fun hudShown(): Boolean = OverlayShown.check(root) || com.droiddeck.launcher.hud.DroidDeckHudHost.showing || + com.droiddeck.launcher.hud.BottomHudPresence.showing // Android hides the load (kgsl sysfs blocked): the kgsl memstore gives the whole GPU's busy share. The in-game // Vulkan layer and the compositor time their own GPU work until the memstore timestamps are seen moving, and diff --git a/app/src/main/java/com/droiddeck/launcher/session/HwmonComponent.kt b/app/src/main/java/com/droiddeck/launcher/session/HwmonComponent.kt index 2cd84747d..66401c7fa 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/HwmonComponent.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/HwmonComponent.kt @@ -108,5 +108,9 @@ class HwmonComponent(val dir: File, private val root: File) : SessionPart() { private fun readNumber(path: String): Long? = runCatching { File(path).readText().trim().toLong() }.getOrNull()?.takeIf { it >= 0 } + + fun fanSource(): String? = FAN_SOURCES().firstOrNull { readNumber(it) != null } + + fun readRpm(path: String): Long? = readNumber(path) } } 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 a41b437f8..6913143ca 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -113,6 +113,31 @@ object SessionPrefs { prefs(context).edit().putString("statsStrip", id).apply() } + fun bottomStripMetrics(context: Context): Set = StripMetric.parse(prefs(context).getString("bottomStripMetrics", null)) + + fun setBottomStripMetric(context: Context, metric: StripMetric, on: Boolean) { + val now = bottomStripMetrics(context).let { if (on) it + metric else it - metric } + prefs(context).edit().putString("bottomStripMetrics", StripMetric.encode(now)).apply() + } + + fun bottomHud(context: Context): Boolean = prefs(context).getBoolean("bottomHud", true) + + fun setBottomHud(context: Context, on: Boolean) { + prefs(context).edit().putBoolean("bottomHud", on).apply() + } + + fun bottomStripOnTabs(context: Context): Boolean = prefs(context).getBoolean("bottomStripTabs", true) + + fun setBottomStripOnTabs(context: Context, on: Boolean) { + prefs(context).edit().putBoolean("bottomStripTabs", on).apply() + } + + fun statsHud(context: Context): String? = prefs(context).getString("statsHud", null) + + fun setStatsHud(context: Context, id: String) { + prefs(context).edit().putString("statsHud", id).apply() + } + fun statsSpeedUnit(context: Context): String? = prefs(context).getString("statsSpeedUnit", null) fun setStatsSpeedUnit(context: Context, id: String) { 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 73d53d57d..b3a222304 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", "statsStrip", "statsDash", "statsSpeedUnit", + "questDefaultsV1", "telemetryAuto", "telemetryOff", "statsStrip", "statsHud", "bottomHud", "bottomStripMetrics", "bottomStripTabs", "statsDash", "statsSpeedUnit", ) 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 index a8857c739..b438ba2f3 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/StatsLayout.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/StatsLayout.kt @@ -1,12 +1,38 @@ package com.droiddeck.launcher.session enum class StatsStrip(val id: String) { - PANEL("panel"), TILES("tiles"), RINGS("rings"); + LINE("line"), 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 + fun from(id: String?): StatsStrip = entries.firstOrNull { it.id == id } ?: LINE + } +} + +enum class StripMetric(val id: String, val defaultOn: Boolean = true) { + ENGINE("engine"), GRAPH("graph"), LOWS("lows"), LOW001("low001"), GPU("gpu"), GPU_TEMP("gputemp", false), + CPU("cpu"), CPU_TEMP("cputemp"), VRAM("vram"), RAM("ram"), CLOCK("clock"); + + companion object { + val defaults: Set = entries.filter { it.defaultOn }.toSet() + + fun parse(saved: String?): Set = + if (saved == null) defaults + else saved.split(',').mapNotNull { id -> entries.firstOrNull { it.id == id.trim() } }.toSet() + + fun encode(on: Set): String = entries.filter { it in on }.joinToString(",") { it.id } + } +} + +enum class StatsHud(val id: String) { + BIG_FPS("bigfps"), FULL("full"), TILES("tiles"), RINGS("rings"), GRAPHS("graphs"), CORES("cores"), + HEAT("heat"), GAME("game"), PACING("pacing"), NIGHT("night"), MEGA("mega"); + + fun next(): StatsHud = entries[(ordinal + 1) % entries.size] + + companion object { + fun from(id: String?): StatsHud = entries.firstOrNull { it.id == id } ?: BIG_FPS } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/BottomDashboard.kt b/app/src/main/java/com/droiddeck/launcher/ui/BottomDashboard.kt index 25fd17149..d5991b5c0 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/BottomDashboard.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/BottomDashboard.kt @@ -87,6 +87,7 @@ fun BottomDashboard( onClose: () -> Unit, onMenu: (() -> Unit)?, gameStarts: Int = 0, + strip: (@Composable (String?) -> Unit)? = null, ) { val colors = MaterialTheme.colorScheme var current by remember { mutableStateOf(pickBottomPage(remembered, pages.map { it.id })) } @@ -110,6 +111,7 @@ fun BottomDashboard( } if (onMenu != null) BarButton(Icons.Outlined.Menu, stringResource(R.string.touch_droiddeck_menu), "bottom-menu", onMenu) } + strip?.invoke(page?.id) Box(Modifier.weight(1f).fillMaxWidth()) { if (page != null) page.content() else Text(stringResource(R.string.bottom_dashboard_empty), fontSize = 15.sp, color = colors.onSurfaceVariant, diff --git a/app/src/main/java/com/droiddeck/launcher/ui/BottomPages.kt b/app/src/main/java/com/droiddeck/launcher/ui/BottomPages.kt index ffe71e400..a8884fdec 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/BottomPages.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/BottomPages.kt @@ -15,8 +15,10 @@ class BottomPageActions( val runningGame: () -> String? = { null }, ) +internal const val BOTTOM_PAGE_TELEMETRY = "telemetry" + fun sessionBottomPages(actions: BottomPageActions): List = listOf( - BottomPage("telemetry", R.string.bottom_page_telemetry, Icons.Outlined.Speed) { BottomTelemetryPage(actions.fps, actions.runningGame) }, + BottomPage(BOTTOM_PAGE_TELEMETRY, R.string.bottom_page_telemetry, Icons.Outlined.Speed) { BottomTelemetryPage(actions.fps, actions.runningGame) }, BottomPage("downloads", R.string.bottom_page_downloads, Icons.Outlined.Download) { BottomDownloadsPage() }, BottomPage("notifications", R.string.bottom_page_notifications, Icons.Outlined.Notifications) { BottomNotificationsPage(actions.openSettings) }, BottomPage("screenshots", R.string.bottom_page_screenshots, Icons.Outlined.PhotoLibrary) { BottomScreenshotsPage(actions.takeScreenshot) }, diff --git a/app/src/main/java/com/droiddeck/launcher/ui/BottomStrip.kt b/app/src/main/java/com/droiddeck/launcher/ui/BottomStrip.kt new file mode 100644 index 000000000..58f1bd76d --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/BottomStrip.kt @@ -0,0 +1,85 @@ +package com.droiddeck.launcher.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import com.droiddeck.launcher.hud.DroidDeckHudConfig +import com.droiddeck.launcher.hud.FrameStats +import com.droiddeck.launcher.hud.GameFrameFeed +import com.droiddeck.launcher.hud.HudFrames +import com.droiddeck.launcher.hud.HudMetrics +import com.droiddeck.launcher.hud.KeyValueSet +import com.droiddeck.launcher.hud.MangoReadings +import com.droiddeck.launcher.runtime.LinuxRuntime +import com.droiddeck.launcher.session.SessionPrefs +import com.droiddeck.launcher.session.StripMetric +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext +import java.io.File + +private const val STRIP_FRAME_MS = 100L +private const val STRIP_METRICS_MS = 500L +private const val STRIP_TEMP_PLAIN = 0xFFF2F5F9.toInt() + +@Composable +internal fun SessionBottomStrip(pageId: String?, fps: () -> Float?) { + val context = LocalContext.current + val frames = remember { GameFrameFeed(File(LinuxRuntime.rootDir(context), HudFrames.PATH)) } + DisposableEffect(frames) { onDispose { frames.close() } } + val page by rememberUpdatedState(pageId) + var frameTick by remember { mutableIntStateOf(0) } + var device by remember { mutableStateOf(null) } + var clock by remember { mutableStateOf("") } + var onTabs by remember { mutableStateOf(SessionPrefs.bottomHud(context) && SessionPrefs.bottomStripOnTabs(context)) } + var metrics by remember { mutableStateOf(SessionPrefs.bottomStripMetrics(context)) } + var thresholds by remember { mutableStateOf>(emptyMap()) } + val tempDisplay = remember { HudMetrics.TempDisplay.from(KeyValueSet(DroidDeckHudConfig.encode(DroidDeckHudConfig.load(context)))) } + LaunchedEffect(frames) { + while (isActive) { + if (frames.poll(System.nanoTime()) || !frames.live) frameTick++ + delay(STRIP_FRAME_MS) + } + } + LaunchedEffect(Unit) { + val reader = HudMetrics(context).apply { setMangoReadings(MangoReadings(File(context.filesDir, "session"), LinuxRuntime.rootDir(context))) } + thresholds = withContext(Dispatchers.IO) { + HudMetrics.TempSensor.values().mapNotNull { sensor -> runCatching { sensor to reader.resolveThresholds(sensor, tempDisplay) }.getOrNull() }.toMap() + } + while (isActive) { + onTabs = SessionPrefs.bottomHud(context) && SessionPrefs.bottomStripOnTabs(context) + metrics = SessionPrefs.bottomStripMetrics(context) + if (onTabs && page != BOTTOM_PAGE_TELEMETRY) { + if (StripMetric.GPU in metrics || StripMetric.VRAM in metrics) com.droiddeck.launcher.hud.BottomHudPresence.beat() + device = withContext(Dispatchers.IO) { runCatching { reader.snapshot() }.getOrNull() } + clock = java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT).format(java.util.Date()) + } + delay(STRIP_METRICS_MS) + } + } + if (!onTabs || pageId == BOTTOM_PAGE_TELEMETRY) return + frameTick.let { + val live = frames.live + val stats = frames.stats + val graph = FloatArray(FrameStats.GRAPH_FRAMES).also { if (live) stats.graph(it) } + LineStrip(StripData( + fps = if (live) stats.fps else fps(), device = device, + fpsHistory = emptyList(), cpuHistory = emptyList(), gpuHistory = emptyList(), wattsHistory = emptyList(), clock = clock, + engine = if (live && !frames.steamFocused) frames.engine else "", + lows = if (live) stats.lows(System.nanoTime()) else null, graph = graph, metrics = metrics, + fahrenheit = tempDisplay.fahrenheit, + tempColor = { sensor, c -> + thresholds[sensor]?.let { Color(HudMetrics.tempColor(c.toFloat(), it, tempDisplay, STRIP_TEMP_PLAIN)) } ?: Color(STRIP_TEMP_PLAIN) + }, + )) + } +} 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 fc1a992ad..41c690f90 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/BottomTelemetryPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/BottomTelemetryPage.kt @@ -1,9 +1,13 @@ package com.droiddeck.launcher.ui +import android.content.Context import android.content.res.Resources +import android.system.Os +import android.system.OsConstants import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight @@ -22,6 +26,7 @@ import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -41,18 +46,30 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.droiddeck.launcher.R +import com.droiddeck.launcher.core.CpuCores +import com.droiddeck.launcher.frontend.AddedGameArt import com.droiddeck.launcher.frontend.Library +import com.droiddeck.launcher.frontend.SteamGameArt +import com.droiddeck.launcher.gpu.GpuInfo +import com.droiddeck.launcher.hud.DroidDeckHudConfig +import com.droiddeck.launcher.hud.DroidDeckHudHost +import com.droiddeck.launcher.hud.FrameStats +import com.droiddeck.launcher.hud.GameFrameFeed +import com.droiddeck.launcher.hud.HudFrames import com.droiddeck.launcher.hud.HudMetrics +import com.droiddeck.launcher.hud.KeyValueSet import com.droiddeck.launcher.hud.MangoReadings import com.droiddeck.launcher.runtime.LinuxRuntime import com.droiddeck.launcher.session.GameStatusFiles import com.droiddeck.launcher.session.GameTelemetry +import com.droiddeck.launcher.session.HwmonComponent import com.droiddeck.launcher.input.SteamTouchConfig import com.droiddeck.launcher.session.SessionPrefs import com.droiddeck.launcher.session.SessionService import com.droiddeck.launcher.session.SessionState import com.droiddeck.launcher.session.SpeedUnit import com.droiddeck.launcher.session.StatsDash +import com.droiddeck.launcher.session.StatsHud import com.droiddeck.launcher.session.StatsStrip import com.droiddeck.launcher.session.Telemetry import com.droiddeck.launcher.session.TelemetryOutput @@ -72,13 +89,16 @@ import java.util.concurrent.atomic.AtomicReference private const val DRAW_MS = 66L private const val TELEMETRY_STALE_MS = 3_000L -private const val METRICS_MS = 1_000L +private const val METRICS_MS = 500L private const val THUNDER_MS = 500L 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 -private const val PERF_N = 30 +private const val PERF_N = 60 +private const val FRAME_MS = 100L +private const val MINUTE_NS = 60_000_000_000L +private const val TEMP_PLAIN = 0xFFF2F5F9.toInt() private class PortTraffic { @Volatile var packets = 0L @@ -124,6 +144,21 @@ fun BottomTelemetryPage(fps: () -> Float?, runningGame: () -> String? = { null } var perfTick by remember { mutableIntStateOf(0) } var traceTick by remember { mutableIntStateOf(0) } var clock by remember { mutableStateOf("") } + val frames = remember { GameFrameFeed(File(LinuxRuntime.rootDir(context), HudFrames.PATH)) } + DisposableEffect(frames) { onDispose { frames.close() } } + val currentFps = { if (frames.live) frames.stats.fps else fps() } + var hud by remember { mutableStateOf(StatsHud.from(SessionPrefs.statsHud(context))) } + var hudOn by remember { mutableStateOf(SessionPrefs.bottomHud(context)) } + var hudTick by remember { mutableIntStateOf(0) } + var fanRpm by remember { mutableStateOf(null) } + var gpuName by remember { mutableStateOf("") } + var proton by remember { mutableStateOf(null) } + var playedSeconds by remember { mutableStateOf(null) } + var cover by remember { mutableStateOf(null) } + var coreGroups by remember { mutableStateOf>(emptyList()) } + var thresholds by remember { mutableStateOf>(emptyMap()) } + val tempDisplay = remember { HudMetrics.TempDisplay.from(KeyValueSet(DroidDeckHudConfig.encode(DroidDeckHudConfig.load(context)))) } + val gameCores = remember { CpuCores.parse(SessionPrefs.gameCpus(context)) } LaunchedEffect(Unit) { for (port in GameTelemetry.PORTS) launch(Dispatchers.IO) { @@ -167,22 +202,49 @@ fun BottomTelemetryPage(fps: () -> Float?, runningGame: () -> String? = { null } speedTrace.add(r.speedKmh ?: 0f) traceTick++ } - fpsNow = fps() + fpsNow = currentFps() unknown = traffic.filter { (_, t) -> t.packets > 0 && !t.readable }.map { (port, t) -> UnknownTraffic(port, t.packets, t.length) } delay(DRAW_MS) } } + LaunchedEffect(frames) { + while (isActive) { + if (frames.poll(System.nanoTime())) hudTick++ + delay(FRAME_MS) + } + } LaunchedEffect(Unit) { val metrics = HudMetrics(context).apply { setMangoReadings(MangoReadings(File(context.filesDir, "session"), LinuxRuntime.rootDir(context))) } + val fanPath = withContext(Dispatchers.IO) { runCatching { HwmonComponent.fanSource() }.getOrNull() } + gpuName = withContext(Dispatchers.IO) { runCatching { GpuInfo.detect().displayName(context) }.getOrDefault("") } + coreGroups = withContext(Dispatchers.IO) { coreGroups() } + thresholds = withContext(Dispatchers.IO) { + HudMetrics.TempSensor.values().mapNotNull { sensor -> runCatching { sensor to metrics.resolveThresholds(sensor, tempDisplay) }.getOrNull() }.toMap() + } + var protonPid = 0 while (isActive) { device = withContext(Dispatchers.IO) { runCatching { metrics.snapshot() }.getOrNull() } - fps()?.takeIf { it > 0f }?.let { fpsHistory.add(it) } + fanRpm = fanPath?.let { path -> withContext(Dispatchers.IO) { HwmonComponent.readRpm(path) } } + val pid = if (frames.live && !frames.steamFocused) frames.pid else 0 + if (pid == 0) { + protonPid = 0 + if (!frames.live) proton = null + } else if (pid != protonPid) { + val name = withContext(Dispatchers.IO) { DroidDeckHudHost.protonName(pid) } + proton = name + if (name != null) protonPid = pid + } + playedSeconds = if (pid > 0 && running != null) withContext(Dispatchers.IO) { processSeconds(pid) } else null + val sampledAt = android.os.SystemClock.elapsedRealtime() + currentFps()?.takeIf { it > 0f }?.let { fpsHistory.add(it, sampledAt) } device?.let { s -> - s.cpuPercent?.let { cpuHistory.add(it.toFloat()) } - s.gpuPercent?.let { gpuHistory.add(it.toFloat()) } + s.cpuPercent?.let { cpuHistory.add(it.toFloat(), sampledAt) } + s.gpuPercent?.let { gpuHistory.add(it.toFloat(), sampledAt) } s.battery?.watts?.takeIf { it > 0f }?.let { wattsHistory.add(it) } } clock = java.text.DateFormat.getTimeInstance(java.text.DateFormat.SHORT).format(java.util.Date()) + hudOn = SessionPrefs.bottomHud(context) + if (hudOn) com.droiddeck.launcher.hud.BottomHudPresence.beat() perfTick++ delay(METRICS_MS) } @@ -221,6 +283,7 @@ fun BottomTelemetryPage(fps: () -> Float?, runningGame: () -> String? = { null } val id = running?.toLongOrNull() gameName = if (id == null) null else withContext(Dispatchers.IO) { runCatching { SteamTouchConfig.gameName(context, id.toInt()) }.getOrNull() } + cover = if (id == null) null else withContext(Dispatchers.IO) { runCatching { gameCover(context, id.toInt()) }.getOrNull() } } LaunchedEffect(rescan) { while (isActive) { @@ -230,96 +293,167 @@ fun BottomTelemetryPage(fps: () -> Float?, runningGame: () -> String? = { null } } } - Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) { - perfTick.let { - StatsStripView(strip, StripData(fpsNow, device, fpsHistory.snapshot(), cpuHistory.snapshot(), gpuHistory.snapshot(), wattsHistory.snapshot(), clock, - if (running != null) gameName else steamClient?.let { stringResource(steamClientLabel(it)) })) { - 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()), shownUnit) { - dash = StatsDash.tapped(shown) - SessionPrefs.setStatsDash(context, dash.id) + val title = if (running != null) gameName else steamClient?.let { stringResource(steamClientLabel(it)) } + val noData = race == null && thunder.isEmpty() && elite == null + BoxWithConstraints(Modifier.fillMaxSize()) { + val viewport = maxHeight + Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(10.dp)) { + if (!hudOn) Unit + else if (noData) hudTick.let { + perfTick.let { + val now = System.nanoTime() + val live = frames.live + val stats = frames.stats + val graph = FloatArray(FrameStats.GRAPH_FRAMES).also { if (live) stats.graph(it) } + val hz = (frames.refreshMilliHz + 499) / 1000 + val data = HudData( + fps = fpsNow, avgFps = if (live) stats.averageFps() else null, lows = if (live) stats.lows(now) else FrameStats.Lows.EMPTY, + graph = graph, slowPerMinute = if (live) stats.slowFrames(now, MINUTE_NS) else null, + engine = if (live && !frames.steamFocused) frames.engine else "", + resolution = if (frames.outputWidth > 0 && frames.outputHeight > 0) { + "${frames.outputWidth}x${frames.outputHeight}" + (if (hz > 0) " @ ${hz}Hz" else "") + } else "", + refreshHz = hz, proton = proton, device = device, gpuName = gpuName, fanRpm = fanRpm, + title = title, cover = cover, played = playedSeconds?.let(::playedText), clock = clock, + fpsHistory = fpsHistory.snapshot(), cpuHistory = cpuHistory.snapshot(), gpuHistory = gpuHistory.snapshot(), + historySeconds = maxOf(fpsHistory.spanSeconds(), cpuHistory.spanSeconds(), gpuHistory.spanSeconds()), + coreGroups = coreGroups, gameCores = gameCores, fahrenheit = tempDisplay.fahrenheit, + tempColor = { sensor, c -> + thresholds[sensor]?.let { Color(HudMetrics.tempColor(c.toFloat(), it, tempDisplay, TEMP_PLAIN)) } ?: Color(TEMP_PLAIN) + }, + redC = { thresholds[it]?.redC }, + ) + StatsHudView(hud, data, Modifier.fillMaxWidth().height(viewport)) { + hud = hud.next() + SessionPrefs.setStatsHud(context, hud.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 -> - 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) }, - e.pips?.takeIf { it.size == 3 }?.let { p -> - stringResource(R.string.bottom_telemetry_pips) to p.joinToString(" · ") { "%.1f".format(it / 2f) } - }, - e.legal?.let { stringResource(R.string.bottom_telemetry_legal) to it }, - e.balance?.let { stringResource(R.string.bottom_telemetry_balance) to "%,d CR".format(it) }, - )) - } - } - if (race == null && thunder.isEmpty() && elite == null) { - Text(stringResource(R.string.bottom_telemetry_help), - fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 4.dp)) - } - if (outputs.isNotEmpty() || menuGames.isNotEmpty()) TelemetryCard(stringResource(R.string.bottom_telemetry_send_title)) { - if (outputs.isNotEmpty()) SwitchRow(stringResource(R.string.bottom_telemetry_send_auto), stringResource(R.string.bottom_telemetry_send_auto_hint), auto, true) { want -> - scope.launch { - withContext(Dispatchers.IO) { - SessionPrefs.setTelemetryAuto(context, want) - if (want) TelemetryOutput.applyAutomatic(context, running) - else TelemetryOutput.restoreTargets(outputs, running).forEach { TelemetryOutput.set(it, false) } + } else hudTick.let { + perfTick.let { + val live = frames.live + val graph = FloatArray(FrameStats.GRAPH_FRAMES).also { if (live) frames.stats.graph(it) } + val data = StripData( + fpsNow, device, fpsHistory.snapshot(), cpuHistory.snapshot(), gpuHistory.snapshot(), wattsHistory.snapshot(), clock, title, + engine = if (live && !frames.steamFocused) frames.engine else "", + lows = if (live) frames.stats.lows(System.nanoTime()) else null, graph = graph, + metrics = SessionPrefs.bottomStripMetrics(context), fahrenheit = tempDisplay.fahrenheit, + tempColor = { sensor, c -> + thresholds[sensor]?.let { Color(HudMetrics.tempColor(c.toFloat(), it, tempDisplay, TEMP_PLAIN)) } ?: Color(TEMP_PLAIN) + }, + ) + StatsStripView(strip, data) { + strip = strip.next() + SessionPrefs.setStatsStrip(context, strip.id) } - auto = want - rescan++ } } - for (game in outputs) { - val locked = TelemetryOutput.isRunning(game.prefixId, running) - val hint = if (locked) stringResource(R.string.bottom_telemetry_send_close, game.name) else null - SwitchRow(game.name, hint, game.on, !locked) { want -> + race?.let { r -> + val shown = dash.resolve(r.source) + traceTick.let { + RaceDashView(shown, r, TraceData(throttleTrace.snapshot(), brakeTrace.snapshot(), speedTrace.snapshot()), shownUnit) { + 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 -> + 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) }, + e.pips?.takeIf { it.size == 3 }?.let { p -> + stringResource(R.string.bottom_telemetry_pips) to p.joinToString(" · ") { "%.1f".format(it / 2f) } + }, + e.legal?.let { stringResource(R.string.bottom_telemetry_legal) to it }, + e.balance?.let { stringResource(R.string.bottom_telemetry_balance) to "%,d CR".format(it) }, + )) + } + } + if (race == null && thunder.isEmpty() && elite == null && running == null) { + Text(stringResource(R.string.bottom_telemetry_help), + fontSize = 14.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 4.dp)) + } + if (outputs.isNotEmpty() || menuGames.isNotEmpty()) TelemetryCard(stringResource(R.string.bottom_telemetry_send_title)) { + if (outputs.isNotEmpty()) SwitchRow(stringResource(R.string.bottom_telemetry_send_auto), stringResource(R.string.bottom_telemetry_send_auto_hint), auto, true) { want -> scope.launch { - val ok = withContext(Dispatchers.IO) { - val key = TelemetryOutput.key(game) - val off = SessionPrefs.telemetryOff(context) - SessionPrefs.setTelemetryOff(context, if (want) off - key else off + key) - TelemetryOutput.set(game, want) + withContext(Dispatchers.IO) { + SessionPrefs.setTelemetryAuto(context, want) + if (want) TelemetryOutput.applyAutomatic(context, running) + else TelemetryOutput.restoreTargets(outputs, running).forEach { TelemetryOutput.set(it, false) } } - outputFailed = if (ok) null else game.name + auto = want rescan++ } } + for (game in outputs) { + val locked = TelemetryOutput.isRunning(game.prefixId, running) + val hint = if (locked) stringResource(R.string.bottom_telemetry_send_close, game.name) else null + SwitchRow(game.name, hint, game.on, !locked) { want -> + scope.launch { + val ok = withContext(Dispatchers.IO) { + val key = TelemetryOutput.key(game) + val off = SessionPrefs.telemetryOff(context) + SessionPrefs.setTelemetryOff(context, if (want) off - key else off + key) + TelemetryOutput.set(game, want) + } + outputFailed = if (ok) null else game.name + rescan++ + } + } + } + for (game in menuGames) { + Text(stringResource(R.string.bottom_telemetry_send_menu, game.name, game.setting, game.port), fontSize = 13.sp, color = colors.onSurfaceVariant) + } + if (outputs.isNotEmpty()) Text(stringResource(R.string.bottom_telemetry_send_note), fontSize = 12.sp, color = colors.onSurfaceVariant) + outputFailed?.let { Text(stringResource(R.string.bottom_telemetry_send_failed, it), fontSize = 12.sp, color = colors.error) } } - for (game in menuGames) { - Text(stringResource(R.string.bottom_telemetry_send_menu, game.name, game.setting, game.port), fontSize = 13.sp, color = colors.onSurfaceVariant) + for (u in unknown) { + Text(stringResource(R.string.bottom_telemetry_unknown_format, u.port, u.packets, u.length), fontSize = 13.sp, + color = colors.error, modifier = Modifier.padding(horizontal = 4.dp)) } - if (outputs.isNotEmpty()) Text(stringResource(R.string.bottom_telemetry_send_note), fontSize = 12.sp, color = colors.onSurfaceVariant) - outputFailed?.let { Text(stringResource(R.string.bottom_telemetry_send_failed, it), fontSize = 12.sp, color = colors.error) } + if (busyPorts.isNotEmpty()) { + Text(stringResource(R.string.bottom_telemetry_port_busy, busyPorts.sorted().joinToString()), fontSize = 13.sp, + color = colors.error, modifier = Modifier.padding(horizontal = 4.dp)) + } + ChoiceRow(stringResource(R.string.stats_speed_units), + if (speedUnit == SpeedUnit.AUTO) stringResource(R.string.stats_speed_units_auto, shownUnit.label) else speedUnit.label) { + speedUnit = speedUnit.next() + SessionPrefs.setStatsSpeedUnit(context, speedUnit.id) + } + Text(stringResource(R.string.stats_tap_hint), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 4.dp)) } - for (u in unknown) { - Text(stringResource(R.string.bottom_telemetry_unknown_format, u.port, u.packets, u.length), fontSize = 13.sp, - color = colors.error, modifier = Modifier.padding(horizontal = 4.dp)) - } - if (busyPorts.isNotEmpty()) { - Text(stringResource(R.string.bottom_telemetry_port_busy, busyPorts.sorted().joinToString()), fontSize = 13.sp, - color = colors.error, modifier = Modifier.padding(horizontal = 4.dp)) - } - ChoiceRow(stringResource(R.string.stats_speed_units), - if (speedUnit == SpeedUnit.AUTO) stringResource(R.string.stats_speed_units_auto, shownUnit.label) else speedUnit.label) { - speedUnit = speedUnit.next() - SessionPrefs.setStatsSpeedUnit(context, speedUnit.id) - } - Text(stringResource(R.string.stats_tap_hint), fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(horizontal = 4.dp)) } } +private fun processSeconds(pid: Int): Long? = runCatching { + val fields = File("/proc/$pid/stat").readText().substringAfterLast(')').trim().split(' ') + val startTicks = fields[19].toLong() + val uptime = File("/proc/uptime").readText().substringBefore(' ').toDouble() + (uptime - startTicks.toDouble() / Os.sysconf(OsConstants._SC_CLK_TCK)).toLong() +}.getOrNull()?.takeIf { it >= 0 } + +private fun playedText(seconds: Long): String = + if (seconds >= 3600) "%d:%02d:%02d".format(seconds / 3600, seconds / 60 % 60, seconds % 60) else "%d:%02d".format(seconds / 60, seconds % 60) + +private fun gameCover(context: Context, appId: Int): File? = + SteamGameArt.chosenFile(context, appId, AddedGameArt.Slot.COVER) + ?: Library.steamCacheImage(File(SessionPrefs.activeSteamRoot(context), "appcache/librarycache"), appId, listOf("library_capsule.jpg", "library_600x900.jpg")) + +private fun coreGroups(): List { + val out = mutableListOf() + for (core in CpuCores.all) { + val ghz = CpuCores.maxGhz(core) + val last = out.lastOrNull() + if (last != null && last.maxGhz == ghz) out[out.lastIndex] = CoreGroup(last.cores + core, ghz) else out += CoreGroup(listOf(core), ghz) + } + return out +} + private fun httpGet(url: String): String { val c = URL(url).openConnection() as HttpURLConnection c.connectTimeout = 300 diff --git a/app/src/main/java/com/droiddeck/launcher/ui/DroidDeckHudSettings.kt b/app/src/main/java/com/droiddeck/launcher/ui/DroidDeckHudSettings.kt index 821fc9af2..505bf4493 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/DroidDeckHudSettings.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/DroidDeckHudSettings.kt @@ -1,10 +1,17 @@ package com.droiddeck.launcher.ui import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import com.droiddeck.launcher.R import com.droiddeck.launcher.hud.DroidDeckHudSize +import com.droiddeck.launcher.session.SessionPrefs +import com.droiddeck.launcher.session.StripMetric @Composable internal fun DroidDeckHudSettings(host: MenuHost, a: DrawerActions, track: (String) -> Modifier) { @@ -31,6 +38,7 @@ internal fun DroidDeckHudSettings(host: MenuHost, a: DrawerActions, track: (Stri ToggleRow(host, "hud-snap", stringResource(R.string.hud_snap), stringResource(R.string.hud_snap_hint), on("hudSnap"), chipModifier = track("hud-snap")) { a.onHudSetting("hudSnap", flag(it)) } } + if (a.dashboardAvailable && a.secondScreenDisplays.isNotEmpty()) BottomStripSettings(host, track) if (!a.hudOn) return SettingsGroup(stringResource(R.string.hud_group_look)) { @@ -96,6 +104,47 @@ internal fun DroidDeckHudSettings(host: MenuHost, a: DrawerActions, track: (Stri private fun flag(on: Boolean) = if (on) "1" else "0" +@Composable +private fun BottomStripSettings(host: MenuHost, track: (String) -> Modifier) { + val context = LocalContext.current + var hudOn by remember { mutableStateOf(SessionPrefs.bottomHud(context)) } + var onTabs by remember { mutableStateOf(SessionPrefs.bottomStripOnTabs(context)) } + var shown by remember { mutableStateOf(SessionPrefs.bottomStripMetrics(context)) } + SettingsGroup(stringResource(R.string.bottom_hud_group), detail = stringResource(R.string.bottom_hud_group_detail)) { + ToggleRow(host, "bottom-hud", stringResource(R.string.bottom_hud_group), stringResource(R.string.bottom_hud_hint), hudOn, + chipModifier = track("bottom-hud")) { on -> + hudOn = on + SessionPrefs.setBottomHud(context, on) + } + ToggleRow(host, "bottom-strip-tabs", stringResource(R.string.bottom_strip_tabs), stringResource(R.string.bottom_strip_tabs_hint), onTabs, + enabled = hudOn, chipModifier = track("bottom-strip-tabs")) { on -> + onTabs = on + SessionPrefs.setBottomStripOnTabs(context, on) + } + for ((metric, label) in BOTTOM_STRIP_ROWS) { + ToggleRow(host, "bottom-strip-${metric.id}", stringResource(label), null, metric in shown, + enabled = hudOn, chipModifier = track("bottom-strip-${metric.id}")) { on -> + shown = if (on) shown + metric else shown - metric + SessionPrefs.setBottomStripMetric(context, metric, on) + } + } + } +} + +private val BOTTOM_STRIP_ROWS = listOf( + StripMetric.ENGINE to R.string.hud_metric_engine, + StripMetric.GRAPH to R.string.hud_metric_graph, + StripMetric.LOWS to R.string.bottom_strip_lows, + StripMetric.LOW001 to R.string.hud_metric_low001, + StripMetric.GPU to R.string.hud_metric_gpu, + StripMetric.GPU_TEMP to R.string.hud_metric_gpu_temp, + StripMetric.CPU to R.string.hud_metric_cpu, + StripMetric.CPU_TEMP to R.string.bottom_strip_cpu_temp, + StripMetric.VRAM to R.string.hud_metric_vram, + StripMetric.RAM to R.string.hud_metric_ram, + StripMetric.CLOCK to R.string.hud_metric_clock, +) + private val EXTRA = mapOf(DroidDeckHudSize.PILL to setOf("Temp", "Power")) private class HudMetric(val chip: String, val key: String, val label: Int) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StatsHud.kt b/app/src/main/java/com/droiddeck/launcher/ui/StatsHud.kt new file mode 100644 index 000000000..8caa4e6de --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/StatsHud.kt @@ -0,0 +1,667 @@ +package com.droiddeck.launcher.ui + +import android.graphics.BlurMaskFilter +import android.graphics.LinearGradient +import android.graphics.Paint +import android.graphics.Shader +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +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.Spacer +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.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +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.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.Shadow +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.layout.ContentScale +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import com.droiddeck.launcher.R +import com.droiddeck.launcher.hud.DroidDeckMark +import com.droiddeck.launcher.hud.FrameStats +import com.droiddeck.launcher.hud.HudMetrics +import com.droiddeck.launcher.session.StatsHud +import java.io.File +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +internal class CoreGroup(val cores: List, val maxGhz: Double?) + +internal class HudData( + val fps: Float?, + val avgFps: Float?, + val lows: FrameStats.Lows, + val graph: FloatArray, + val slowPerMinute: Int?, + val engine: String, + val resolution: String, + val refreshHz: Int, + val proton: String?, + val device: HudMetrics.Snapshot?, + val gpuName: String, + val fanRpm: Long?, + val title: String?, + val cover: File?, + val played: String?, + val clock: String, + val fpsHistory: List, + val cpuHistory: List, + val gpuHistory: List, + val coreGroups: List, + val gameCores: Set, + val fahrenheit: Boolean, + val tempColor: (HudMetrics.TempSensor, Int) -> Color, + val redC: (HudMetrics.TempSensor) -> Int?, + val historySeconds: Int = 0, +) { + val frametimeMs: Float? get() = fps?.takeIf { it > 0f }?.let { 1000f / it } + val hz: Int get() = if (refreshHz > 0) refreshHz else 60 +} + +private object HudInk { + val value = Color(0xFFF2F5F9) + val dim = Color(0xFF9AA4B2) + val low = Color(0xFFE4E8EE) + val vram = Color(0xFFC98BFF) + val sep = Color(0xFF5D6876) + val header = Color(0xFFEAF4FF) + val amber = Color(0xFFFFB74D) + val tile = Color(0x0EFFFFFF) +} + +private val Mono = FontFamily.Monospace +private val HEADER = 40.dp +private const val UNIT = 0.62f + +@Composable +internal fun StatsHudView(style: StatsHud, d: HudData, modifier: Modifier, onTap: () -> Unit) { + val accent = LocalPalette.current.primary + val night = style == StatsHud.NIGHT + Box(modifier.clickable(onClick = onTap)) { + HudPanel(d.clock, if (style == StatsHud.GRAPHS) stringResource(R.string.stats_hud_last_seconds, d.historySeconds) else null, night, accent) { + when (style) { + StatsHud.BIG_FPS -> BigFps(d, accent) + StatsHud.FULL -> Full(d, accent) + StatsHud.TILES -> Tiles(d, accent) + StatsHud.RINGS -> Rings(d, accent) + StatsHud.GRAPHS -> Graphs(d, accent) + StatsHud.CORES -> Cores(d, accent) + StatsHud.HEAT -> Heat(d) + StatsHud.GAME -> GameCard(d, accent) + StatsHud.PACING -> Pacing(d, accent) + StatsHud.NIGHT -> Night(d, night) + StatsHud.MEGA -> Mega(d, accent) + } + } + } +} + +@Composable +private fun HudPanel(clock: String, right: String?, dim: Boolean, accent: Color, content: @Composable ColumnScope.() -> Unit) { + val headerInk = if (dim) Color(0xFF7D8794) else HudInk.header + Column( + Modifier.fillMaxSize() + .drawWithCache { + val w = size.width + val h = size.height + val stroke = 2.dp.toPx() + val corner = 7.dp.toPx() + val radius = min(h / 2f, 64.dp.toPx()) + val headerPx = HEADER.toPx() + val a = accent.toArgb() + val body = dPath(0f, 0f, w, h, radius, corner) + val half = stroke / 2f + val outline = dPath(half, half, w - half, h - half, radius - half, corner) + val fill = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = if (dim) argb(245, 0xFF040507.toInt()) else argb(240, 0xFF0E1014.toInt()) } + val band = Paint(Paint.ANTI_ALIAS_FLAG).apply { + shader = LinearGradient(0f, 0f, w, 0f, argb(if (dim) 20 else 56, a), argb(0, a), Shader.TileMode.CLAMP) + } + val divider = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = argb(if (dim) 38 else 90, a) } + val blur = BlurMaskFilter(4.dp.toPx(), BlurMaskFilter.Blur.NORMAL) + val line = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE; strokeWidth = stroke; color = argb(if (dim) 90 else 255, a) } + val markH = min(h, w) * 0.55f + val markX = w - DroidDeckMark.widthFor(markH) - 16.dp.toPx() + val markBody = android.graphics.Color.argb(if (dim) 9 else 20, 255, 255, 255) + val markOrb = argb(if (dim) 9 else 20, a) + onDrawBehind { + drawIntoCanvas { canvas -> + val c = canvas.nativeCanvas + c.drawPath(body, fill) + c.save() + c.clipPath(body) + c.drawRect(0f, 0f, w, headerPx + 1f, band) + c.drawRect(0f, headerPx, w, headerPx + max(1f, density), divider) + DroidDeckMark.draw(c, markX, (h - markH) / 2f, markH, markBody, markOrb) + c.restore() + for (pass in 0..2) { + line.maskFilter = if (pass < 2) blur else null + c.drawPath(outline, line) + } + line.maskFilter = null + } + } + } + .padding(start = 24.dp, end = 34.dp, bottom = 14.dp), + ) { + Row(Modifier.fillMaxWidth().height(HEADER), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + DroidDeckLogo(16.dp, body = headerInk, orb = if (dim) accent.copy(alpha = 0.5f) else accent) + Text("DroidDeck", fontSize = 14.sp, fontWeight = FontWeight.Bold, color = headerInk, maxLines = 1) + if (clock.isNotEmpty()) { + Text("·", fontSize = 13.sp, fontFamily = Mono, color = HudInk.sep) + Text(clock, fontSize = 13.sp, fontFamily = Mono, fontWeight = FontWeight.Bold, color = HudInk.dim, maxLines = 1) + } + if (right != null) { + Spacer(Modifier.weight(1f)) + Text(right, fontSize = 13.sp, fontFamily = Mono, fontWeight = FontWeight.Bold, color = HudInk.dim, maxLines = 1) + } + } + Spacer(Modifier.height(12.dp)) + content() + } +} + +private fun argb(alpha: Int, rgb: Int): Int = (alpha shl 24) or (rgb and 0xFFFFFF) + +private fun dPath(l: Float, t: Float, r: Float, b: Float, rad: Float, corner: Float) = android.graphics.Path().apply { + moveTo(l, t + corner) + lineTo(l, b - corner); quadTo(l, b, l + corner, b) + lineTo(r - rad, b) + arcTo(r - 2f * rad, b - 2f * rad, r, b, 90f, -90f, false) + lineTo(r, t + rad) + arcTo(r - 2f * rad, t, r, t + 2f * rad, 0f, -90f, false) + lineTo(l + corner, t); quadTo(l, t, l, t + corner) + close() +} + +private fun f1(v: Float?): String = v?.takeIf { it > 0f }?.let { "%.1f".format(it) } ?: "–" + +private fun gib(bytes: Long?): String = bytes?.let { "%.1f".format(it / 1073741824.0) } ?: "–" + +private fun AnnotatedString.Builder.num(value: String, unit: String, size: TextUnit, color: Color = HudInk.value, unitScale: Float = UNIT) { + withStyle(SpanStyle(color = color)) { append(value) } + if (unit.isNotEmpty()) withStyle(SpanStyle(color = HudInk.dim, fontSize = size * unitScale)) { append(unit) } +} + +private fun AnnotatedString.Builder.gap() = append(" ") + +private fun tempLabel(d: HudData, c: Int): String = if (d.fahrenheit) "${c * 9 / 5 + 32}°F" else "$c°C" + +private fun AnnotatedString.Builder.temp(d: HudData, sensor: HudMetrics.TempSensor, c: Int?, size: TextUnit) { + if (c == null) { num("–", "", size); return } + val shown = if (d.fahrenheit) (c * 9 / 5 + 32) else c + num(shown.toString(), if (d.fahrenheit) "°F" else "°C", size, d.tempColor(sensor, c)) +} + +@Composable +private fun Big(value: String, unit: String, size: TextUnit, color: Color, glow: Boolean, unitScale: Float = 0.27f, modifier: Modifier = Modifier) { + Text( + buildAnnotatedString { num(value, unit, size, color, unitScale) }, + modifier = modifier, fontSize = size, fontFamily = Mono, fontWeight = FontWeight.Bold, maxLines = 1, softWrap = false, + style = if (glow) TextStyle(shadow = Shadow(color.copy(alpha = 0.85f), Offset.Zero, 18f)) else TextStyle.Default, + ) +} + +@Composable +private fun Line(text: AnnotatedString, size: TextUnit, modifier: Modifier = Modifier, align: TextAlign = TextAlign.Start) { + Text(text, modifier = modifier, fontSize = size, fontFamily = Mono, fontWeight = FontWeight.Bold, color = HudInk.value, maxLines = 1, + softWrap = false, overflow = TextOverflow.Clip, textAlign = align) +} + +@Composable +private fun Small(text: String, size: TextUnit = 12.sp, color: Color = HudInk.dim, modifier: Modifier = Modifier) { + Text(text, modifier = modifier, fontSize = size, fontFamily = Mono, fontWeight = FontWeight.Bold, color = color, maxLines = 1, overflow = TextOverflow.Ellipsis) +} + +private fun lowsText(d: HudData, size: TextUnit, accent: Color, deep: Boolean = true): AnnotatedString = buildAnnotatedString { + fun part(label: String, v: Float) { + withStyle(SpanStyle(color = HudInk.dim)) { append("$label ") } + withStyle(SpanStyle(color = accent)) { append(f1(v)) } + } + part("1%", d.lows.low1Fps) + withStyle(SpanStyle(color = HudInk.sep)) { append(" · ") } + part("0.1%", d.lows.low01Fps) + if (deep) { + withStyle(SpanStyle(color = HudInk.sep)) { append(" · ") } + part("0.01%", d.lows.low001Fps) + } +} + +@Composable +private fun Graph(values: List, lo: Float, hi: Float, color: Color, modifier: Modifier) { + Canvas(modifier) { + if (values.size < 2 || hi <= lo) return@Canvas + val path = Path() + values.forEachIndexed { i, v -> + val x = i / (values.size - 1f) * size.width + val y = size.height - ((v - lo) / (hi - lo)).coerceIn(0f, 1f) * size.height + if (i == 0) path.moveTo(x, y) else path.lineTo(x, y) + } + drawPath(path, color.copy(alpha = 0.22f), style = Stroke(width = 6.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round)) + drawPath(path, color, style = Stroke(width = 2.2f.dp.toPx(), cap = StrokeCap.Round, join = StrokeJoin.Round)) + } +} + +@Composable +private fun FrameGraph(d: HudData, accent: Color, height: Dp) { + Graph(d.graph.toList(), 0f, FrameStats.GRAPH_MAX_MS, lerp(accent, Color.White, 0.3f), Modifier.fillMaxWidth().height(height)) +} + +@Composable +private fun Rows(rows: List, AnnotatedString>>, size: TextUnit, labelWidth: Dp, gap: Dp) { + Column(verticalArrangement = Arrangement.spacedBy(gap)) { + for ((label, value) in rows) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text(label.first, modifier = Modifier.width(labelWidth), fontSize = size, fontFamily = Mono, fontWeight = FontWeight.Bold, + color = label.second, maxLines = 1, softWrap = false) + Line(value, size) + } + } + } +} + +private fun row(label: String, color: Color, value: AnnotatedString) = (label to color) to value + +@Composable +private fun RowScope.Tile(key: String, keyColor: Color, value: AnnotatedString, sub: String, glow: Color? = null) { + Column( + Modifier.weight(1f).fillMaxHeight().clip(RoundedCornerShape(9.dp)).background(HudInk.tile).padding(horizontal = 11.dp, vertical = 6.dp), + verticalArrangement = Arrangement.Center, + ) { + Small(key, 12.sp, keyColor) + Text(value, fontSize = 28.sp, fontFamily = Mono, fontWeight = FontWeight.Bold, color = HudInk.value, maxLines = 1, softWrap = false, + style = if (glow != null) TextStyle(shadow = Shadow(glow.copy(alpha = 0.85f), Offset.Zero, 16f)) else TextStyle.Default) + Small(sub) + } +} + +@Composable +private fun Ring(ringSize: Dp, fraction: Float?, color: Color, label: String, sub: String?, center: @Composable () -> Unit) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box(Modifier.size(ringSize), contentAlignment = Alignment.Center) { + Canvas(Modifier.fillMaxSize()) { + val w = size.minDimension * 0.085f + val tl = Offset(w, w) + val sz = Size(size.width - 2 * w, size.height - 2 * w) + drawArc(Color.White.copy(alpha = 0.08f), 0f, 360f, false, topLeft = tl, size = sz, style = Stroke(width = w)) + if (fraction != null) { + val sweep = (fraction.coerceIn(0f, 1f) * 360f).coerceAtLeast(1f) + drawArc(color.copy(alpha = 0.25f), -90f, sweep, false, topLeft = tl, size = sz, style = Stroke(width = w * 1.9f, cap = StrokeCap.Round)) + drawArc(color, -90f, sweep, false, topLeft = tl, size = sz, style = Stroke(width = w, cap = StrokeCap.Round)) + } + } + center() + } + Small(label, 13.sp, color) + if (sub != null) Small(sub, 11.5.sp) + } +} + +@Composable +private fun ColumnScope.BigFps(d: HudData, accent: Color) { + Big(f1(d.fps), "fps", 104.sp, accent, glow = true, modifier = Modifier.offset(x = (-4).dp)) + Line(lowsText(d, 16.sp, accent), 16.sp, Modifier.padding(top = 10.dp)) + Spacer(Modifier.weight(1f)) + FrameGraph(d, accent, 72.dp) + Small(listOfNotNull(d.engine.ifEmpty { null }, d.title, d.played).joinToString(" · "), modifier = Modifier.padding(top = 8.dp)) +} + +@Composable +private fun ColumnScope.Full(d: HudData, accent: Color) { + val s = d.device + val z = 16.sp + val left = listOf( + row("GPU", StatsColors.gpu, AnnotatedString(d.gpuName)), + row("GPU", StatsColors.gpu, buildAnnotatedString { num(s?.gpuPercent?.toString() ?: "–", "%", z); gap(); temp(d, HudMetrics.TempSensor.GPU, s?.gpuTempC, z) }), + row("", StatsColors.gpu, buildAnnotatedString { num(s?.gpuClockMhz?.toString() ?: "–", "MHz", z) }), + row("CPU", StatsColors.cpu, buildAnnotatedString { num(s?.cpuPercent?.toString() ?: "–", "%", z); gap(); temp(d, HudMetrics.TempSensor.CPU, s?.cpuTempC, z) }), + row("", StatsColors.cpu, buildAnnotatedString { num(s?.cpuClockMhz?.toString() ?: "–", "MHz", z) }), + row("VRAM", HudInk.vram, buildAnnotatedString { num(gib(s?.vramUsedBytes), "GiB", z) }), + row("RAM", StatsColors.ram, buildAnnotatedString { num(gib(s?.ramUsedBytes), "GiB", z); gap(); num(s?.ramPercent?.roundToInt()?.toString() ?: "–", "%", z) }), + row("BAT", StatsColors.bat, buildAnnotatedString { num(s?.battery?.percent?.toString() ?: "–", "%", z); gap(); num(s?.battery?.watts?.takeIf { it > 0f }?.let { "%.1f".format(it) } ?: "–", "W", z) }), + ) + val right = listOf( + row(d.engine.ifEmpty { "FPS" }, accent, buildAnnotatedString { num(f1(d.fps), "FPS", z, accent) }), + row("", accent, buildAnnotatedString { num(f1(d.frametimeMs), "ms", z) }), + row("AVG", HudInk.low, buildAnnotatedString { num(f1(d.avgFps), "FPS", z) }), + row("1%", HudInk.low, buildAnnotatedString { num(f1(d.lows.low1Fps), "FPS", z) }), + row("0.1%", HudInk.low, buildAnnotatedString { num(f1(d.lows.low01Fps), "FPS", z) }), + row("0.01%", HudInk.low, buildAnnotatedString { num(f1(d.lows.low001Fps), "FPS", z) }), + row("TEMP", StatsColors.bat, buildAnnotatedString { temp(d, HudMetrics.TempSensor.BATTERY, s?.battery?.tempC, z) }), + row("FAN", HudInk.dim, buildAnnotatedString { num(d.fanRpm?.toString() ?: "–", "rpm", z) }), + ) + Row(horizontalArrangement = Arrangement.spacedBy(28.dp)) { + Rows(left, z, 52.dp, 6.dp) + Rows(right, z, 62.dp, 6.dp) + } + Spacer(Modifier.weight(1f)) + val frametime = stringResource(R.string.stats_hud_frametime) + Line(buildAnnotatedString { + withStyle(SpanStyle(color = accent)) { append(frametime) } + withStyle(SpanStyle(color = HudInk.dim)) { append(" min %.1f · max %.1f ms".format(d.graph.filter { it > 0f }.minOrNull() ?: 0f, d.graph.maxOrNull() ?: 0f)) } + }, 12.5.sp, Modifier.padding(bottom = 6.dp)) + FrameGraph(d, accent, 44.dp) +} + +@Composable +private fun ColumnScope.Tiles(d: HudData, accent: Color) { + val s = d.device + val z = 28.sp + val u = 0.5f + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Tile("FPS", accent, buildAnnotatedString { num(f1(d.fps), "", z) }, "${f1(d.avgFps)} avg · ${f1(d.lows.low1Fps)} 1%", glow = accent) + Tile("FRAME", HudInk.dim, buildAnnotatedString { num(f1(d.frametimeMs), "ms", z, unitScale = u) }, "max %.1f ms".format(d.graph.maxOrNull() ?: 0f)) + Tile("GPU", StatsColors.gpu, buildAnnotatedString { num(s?.gpuPercent?.toString() ?: "–", "%", z, unitScale = u) }, + listOfNotNull(s?.gpuTempC?.let { tempLabel(d, it) }, s?.gpuClockMhz?.let { "$it MHz" }).joinToString(" · ")) + } + Row(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Tile("CPU", StatsColors.cpu, buildAnnotatedString { num(s?.cpuPercent?.toString() ?: "–", "%", z, unitScale = u) }, + listOfNotNull(s?.cpuTempC?.let { tempLabel(d, it) }, s?.cpuClockMhz?.let { "$it MHz" }).joinToString(" · ")) + Tile("RAM", StatsColors.ram, buildAnnotatedString { num(s?.ramPercent?.roundToInt()?.toString() ?: "–", "%", z, unitScale = u) }, + "${gib(s?.ramUsedBytes)} / ${gib(s?.ramTotalBytes)} GiB") + Tile("VRAM", HudInk.vram, buildAnnotatedString { num(gib(s?.vramUsedBytes), "GiB", z, unitScale = u) }, d.gpuName) + } + Row(Modifier.weight(1f), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Tile("BAT TEMP", StatsColors.bat, buildAnnotatedString { temp(d, HudMetrics.TempSensor.BATTERY, s?.battery?.tempC, z) }, stringResource(R.string.stats_hud_battery_temp)) + Tile("FAN", HudInk.dim, buildAnnotatedString { num(d.fanRpm?.toString() ?: "–", "", z) }, "rpm") + Tile("BATTERY", StatsColors.bat, buildAnnotatedString { num(s?.battery?.percent?.toString() ?: "–", "%", z, unitScale = u) }, + listOfNotNull(s?.battery?.watts?.takeIf { it > 0f }?.let { "%.1f W".format(it) }, s?.battery?.runtimeText).joinToString(" · ")) + } + } +} + +@Composable +private fun ColumnScope.Rings(d: HudData, accent: Color) { + val s = d.device + Row(Modifier.weight(1f).fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(26.dp)) { + Ring(200.dp, d.fps?.let { it / d.hz }, accent, stringResource(R.string.stats_hud_fps_of, d.hz), "1% ${f1(d.lows.low1Fps)} · 0.1% ${f1(d.lows.low01Fps)}") { + Big(f1(d.fps), "fps", 40.sp, HudInk.value, glow = true, unitScale = 0.4f) + } + Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(22.dp)) { + Ring(96.dp, s?.gpuPercent?.let { it / 100f }, StatsColors.gpu, "GPU", s?.gpuTempC?.let { tempLabel(d, it) }) { Big(s?.gpuPercent?.toString() ?: "–", "%", 20.sp, HudInk.value, false, 0.6f) } + Ring(96.dp, s?.cpuPercent?.let { it / 100f }, StatsColors.cpu, "CPU", s?.cpuTempC?.let { tempLabel(d, it) }) { Big(s?.cpuPercent?.toString() ?: "–", "%", 20.sp, HudInk.value, false, 0.6f) } + } + Row(horizontalArrangement = Arrangement.spacedBy(22.dp)) { + Ring(96.dp, s?.ramPercent?.let { it / 100f }, StatsColors.ram, "RAM", s?.let { "${gib(it.ramUsedBytes)} GiB" }) { + Big(s?.ramPercent?.roundToInt()?.toString() ?: "–", "%", 20.sp, HudInk.value, false, 0.6f) + } + Ring(96.dp, s?.battery?.percent?.let { it / 100f }, StatsColors.bat, "BAT", s?.battery?.watts?.takeIf { it > 0f }?.let { "%.1f W".format(it) }) { + Big(s?.battery?.percent?.toString() ?: "–", "%", 20.sp, HudInk.value, false, 0.6f) + } + } + } + } +} + +@Composable +private fun ColumnScope.Graphs(d: HudData, accent: Color) { + val s = d.device + val frameMs = d.fpsHistory.map { 1000f / max(it, 1f) } + @Composable + fun graphRow(label: String, color: Color, values: List, hi: Float, value: AnnotatedString) { + Row(Modifier.fillMaxWidth().height(56.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Small(label, 14.sp, color, Modifier.width(62.dp)) + Graph(values, 0f, hi, color, Modifier.weight(1f).height(52.dp)) + Line(value, 24.sp, Modifier.width(128.dp), TextAlign.End) + } + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.SpaceEvenly) { + graphRow("FPS", accent, d.fpsHistory, max(d.hz.toFloat(), d.fpsHistory.maxOrNull() ?: 0f) * 1.1f, buildAnnotatedString { num(f1(d.fps), "fps", 24.sp, accent, 0.5f) }) + graphRow("FRAME", lerp(accent, Color.White, 0.3f), frameMs, max(40f, (frameMs.maxOrNull() ?: 0f) * 1.2f), buildAnnotatedString { num(f1(d.frametimeMs), "ms", 24.sp, unitScale = 0.5f) }) + graphRow("CPU", StatsColors.cpu, d.cpuHistory, 100f, buildAnnotatedString { num(s?.cpuPercent?.toString() ?: "–", "%", 24.sp, unitScale = 0.5f) }) + graphRow("GPU", StatsColors.gpu, d.gpuHistory, 100f, buildAnnotatedString { num(s?.gpuPercent?.toString() ?: "–", "%", 24.sp, unitScale = 0.5f) }) + } +} + +@Composable +private fun ColumnScope.Cores(d: HudData, accent: Color) { + val s = d.device + Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Big(f1(d.fps), "fps", 40.sp, accent, glow = true, unitScale = 0.38f) + Small(listOfNotNull(d.engine.ifEmpty { null }, d.frametimeMs?.let { "%.1f ms".format(it) }, + s?.cpuPercent?.let { "CPU $it%" }, s?.cpuTempC?.let { tempLabel(d, it) }).joinToString(" · "), 15.sp, modifier = Modifier.padding(bottom = 6.dp)) + } + Spacer(Modifier.height(10.dp)) + val gameTag = stringResource(R.string.stats_hud_game_cores) + Column(verticalArrangement = Arrangement.spacedBy(5.dp)) { + for (group in d.coreGroups) { + for (core in group.cores) { + val p = s?.perCorePercent?.getOrNull(core)?.takeIf { it >= 0 } + val mhz = s?.perCoreClockMhz?.getOrNull(core)?.takeIf { it > 0 } + Row(Modifier.fillMaxWidth().height(20.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Small("C$core", 14.sp, StatsColors.cpu, Modifier.width(40.dp)) + Box(Modifier.weight(1f).height(14.dp).clip(RoundedCornerShape(7.dp)).background(Color.White.copy(alpha = 0.07f))) { + if (p != null && p > 0) Box( + Modifier.fillMaxHeight().fillMaxWidth(p / 100f).clip(RoundedCornerShape(7.dp)) + .background(Brush.horizontalGradient(listOf(Color(0xFF2B6FC9), if (p >= 80) HudInk.amber else StatsColors.cpu))), + ) + } + Line(buildAnnotatedString { num(p?.toString() ?: "–", "%", 14.sp); append(" "); num(mhz?.toString() ?: "–", "MHz", 14.sp) }, + 14.sp, Modifier.width(118.dp), TextAlign.End) + } + } + val label = group.maxGhz?.let { stringResource(R.string.stats_hud_core_group, group.cores.size, "%.2f".format(it)) } + ?: stringResource(R.string.stats_hud_core_group_unknown, group.cores.size) + Row(Modifier.padding(start = 50.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Small(label, 11.5.sp) + if (d.gameCores.isNotEmpty() && group.cores.any { it in d.gameCores }) { + Text(gameTag.uppercase(), fontSize = 10.sp, fontFamily = Mono, fontWeight = FontWeight.Bold, color = Color(0xFF0A0B0D), + modifier = Modifier.clip(RoundedCornerShape(4.dp)).background(StatsColors.cpu).padding(horizontal = 4.dp)) + } + } + } + } +} + +@Composable +private fun ColumnScope.Heat(d: HudData) { + val s = d.device + @Composable + fun bar(label: String, color: Color, sensor: HudMetrics.TempSensor, c: Int?) { + val red = d.redC(sensor) + Row(Modifier.fillMaxWidth().height(28.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Small(label, 15.sp, color, Modifier.width(48.dp)) + BoxWithConstraints(Modifier.weight(1f).height(20.dp).clip(RoundedCornerShape(10.dp)).background(Color.White.copy(alpha = 0.07f))) { + if (c != null && c > 0) Box(Modifier.fillMaxHeight().fillMaxWidth((c / 100f).coerceIn(0f, 1f)).clip(RoundedCornerShape(10.dp)) + .background(Brush.horizontalGradient(listOf(color.copy(alpha = 0.55f), color)))) + if (red != null && red in 1..99) Box(Modifier.offset(x = maxWidth * (red / 100f)).width(2.dp).fillMaxHeight().background(Color(0xFFEF5350))) + } + Line(buildAnnotatedString { temp(d, sensor, c, 24.sp) }, 24.sp, Modifier.width(92.dp), TextAlign.End) + } + } + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + bar("CPU", StatsColors.cpu, HudMetrics.TempSensor.CPU, s?.cpuTempC) + bar("GPU", StatsColors.gpu, HudMetrics.TempSensor.GPU, s?.gpuTempC) + bar("BAT", StatsColors.bat, HudMetrics.TempSensor.BATTERY, s?.battery?.tempC) + } + Small(stringResource(R.string.stats_hud_red_mark), 12.sp, modifier = Modifier.padding(top = 8.dp)) + Spacer(Modifier.weight(1f)) + Row(Modifier.fillMaxWidth().height(96.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Tile("FAN", HudInk.dim, buildAnnotatedString { num(d.fanRpm?.toString() ?: "–", "", 28.sp) }, "rpm") + Tile("POWER", StatsColors.bat, buildAnnotatedString { num(s?.battery?.watts?.takeIf { it > 0f }?.let { "%.1f".format(it) } ?: "–", "W", 28.sp, unitScale = 0.5f) }, + stringResource(R.string.stats_hud_whole_device)) + Tile("BATTERY", StatsColors.bat, buildAnnotatedString { num(s?.battery?.percent?.toString() ?: "–", "%", 28.sp, unitScale = 0.5f) }, s?.battery?.runtimeText ?: "") + } +} + +@Composable +private fun ColumnScope.GameCard(d: HudData, accent: Color) { + Row(Modifier.weight(1f).fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(20.dp)) { + Box( + Modifier.width(150.dp).fillMaxHeight().clip(RoundedCornerShape(10.dp)) + .background(Brush.linearGradient(listOf(lerp(accent, Color.Black, 0.55f), Color(0xFF0B0D10)))), + contentAlignment = Alignment.BottomStart, + ) { + if (d.cover != null) AsyncImage(model = d.cover, contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) + else Text(d.title.orEmpty(), fontSize = 20.sp, fontWeight = FontWeight.Bold, color = Color.White.copy(alpha = 0.85f), maxLines = 3, + modifier = Modifier.padding(12.dp)) + } + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text(d.title.orEmpty(), fontSize = 26.sp, fontWeight = FontWeight.Bold, color = Color.White, maxLines = 2, overflow = TextOverflow.Ellipsis) + d.played?.let { Small(stringResource(R.string.stats_hud_playing_for, it), 13.5.sp) } + Line(buildAnnotatedString { + withStyle(SpanStyle(color = accent)) { append(d.engine) } + if (d.engine.isNotEmpty() && d.proton != null) withStyle(SpanStyle(color = HudInk.dim)) { append(" · ") } + d.proton?.let { withStyle(SpanStyle(color = HudInk.vram)) { append(it) } } + }, 13.5.sp) + if (d.resolution.isNotEmpty()) Small(d.resolution, 13.5.sp) + Big(f1(d.fps), "fps", 60.sp, accent, glow = true, unitScale = 0.3f, modifier = Modifier.padding(top = 6.dp)) + Line(lowsText(d, 14.sp, accent, deep = false), 14.sp) + } + } + Spacer(Modifier.height(10.dp)) + FrameGraph(d, accent, 40.dp) +} + +@Composable +private fun ColumnScope.Pacing(d: HudData, accent: Color) { + @Composable + fun stat(label: String, value: String, color: Color = HudInk.value) { + Column { + Small(label, 13.sp) + Text(value, fontSize = 22.sp, fontFamily = Mono, fontWeight = FontWeight.Bold, color = color, maxLines = 1) + } + } + Row(verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(24.dp)) { + Big(f1(d.fps), "fps", 52.sp, accent, glow = true, unitScale = 0.3f) + stat(stringResource(R.string.stats_hud_avg_frame), d.frametimeMs?.let { "%.1f ms".format(it) } ?: "–") + stat(stringResource(R.string.stats_hud_low), f1(d.lows.low1Fps)) + val slow = d.slowPerMinute + stat(stringResource(R.string.stats_hud_slow_frames), slow?.let { stringResource(R.string.stats_hud_per_minute, it) } ?: "–", + if ((slow ?: 0) > 0) HudInk.amber else HudInk.value) + } + Spacer(Modifier.height(12.dp)) + val target = 1000f / d.hz + BoxWithConstraints(Modifier.weight(1f).fillMaxWidth()) { + val graphColor = lerp(accent, Color.White, 0.3f) + Canvas(Modifier.fillMaxSize()) { + val axis = Color.White.copy(alpha = 0.12f) + drawLine(axis, Offset(0f, 0f), Offset(0f, size.height), 1.dp.toPx()) + drawLine(axis, Offset(0f, size.height), Offset(size.width, size.height), 1.dp.toPx()) + val dash = PathEffect.dashPathEffect(floatArrayOf(6.dp.toPx(), 5.dp.toPx())) + for ((ms, color) in listOf(target to Color.White.copy(alpha = 0.25f), target * 2 to HudInk.amber.copy(alpha = 0.5f))) { + val y = size.height * (1f - ms / FrameStats.GRAPH_MAX_MS) + drawLine(color, Offset(0f, y), Offset(size.width, y), 1.dp.toPx(), pathEffect = dash) + } + } + Graph(d.graph.toList(), 0f, FrameStats.GRAPH_MAX_MS, graphColor, Modifier.fillMaxSize()) + for ((ms, color) in listOf(target to HudInk.dim, target * 2 to HudInk.amber)) { + Small("%.1f ms · %d fps".format(ms, (1000f / ms).roundToInt()), 11.sp, color, + Modifier.align(Alignment.TopEnd).offset(y = maxHeight * (1f - ms / FrameStats.GRAPH_MAX_MS) - 17.dp)) + } + } +} + +@Composable +private fun ColumnScope.Night(d: HudData, dim: Boolean) { + val clockInk = if (dim) Color(0xFF6F7A87) else HudInk.value + val fpsInk = if (dim) Color(0xFF4D7FA8) else LocalPalette.current.primary + Column(Modifier.weight(1f), verticalArrangement = Arrangement.Center) { + Text(d.clock, fontSize = 88.sp, fontWeight = FontWeight.Light, color = clockInk, maxLines = 1, softWrap = false) + Line(buildAnnotatedString { + num(f1(d.fps), "fps", 30.sp, fpsInk) + d.device?.battery?.percent?.let { + withStyle(SpanStyle(color = HudInk.sep)) { append(" · ") } + num(it.toString(), "%", 30.sp, if (dim) Color(0xFF6B5A44) else StatsColors.bat) + } + }, 30.sp, Modifier.padding(top = 14.dp)) + } +} + +@Composable +private fun ColumnScope.Mega(d: HudData, accent: Color) { + val s = d.device + val z = 13.5.sp + val left = mutableListOf( + row("GPU", StatsColors.gpu, AnnotatedString(d.gpuName)), + row("GPU", StatsColors.gpu, buildAnnotatedString { + num(s?.gpuPercent?.toString() ?: "–", "%", z); gap(); temp(d, HudMetrics.TempSensor.GPU, s?.gpuTempC, z); gap(); num(s?.gpuClockMhz?.toString() ?: "–", "MHz", z) + }), + row("CPU", StatsColors.cpu, buildAnnotatedString { + num(s?.cpuPercent?.toString() ?: "–", "%", z); gap(); temp(d, HudMetrics.TempSensor.CPU, s?.cpuTempC, z); gap(); num(s?.cpuClockMhz?.toString() ?: "–", "MHz", z) + }), + ) + val cores = s?.perCorePercent?.size ?: 0 + for (i in 0 until cores) { + left += row("C$i", StatsColors.cpu, buildAnnotatedString { + num(s?.perCorePercent?.getOrNull(i)?.takeIf { it >= 0 }?.toString() ?: "–", "%", z); gap() + num(s?.perCoreClockMhz?.getOrNull(i)?.takeIf { it > 0 }?.toString() ?: "–", "MHz", z) + }) + } + val right = listOf( + row("VRAM", HudInk.vram, buildAnnotatedString { num(gib(s?.vramUsedBytes), "GiB", z) }), + row("RAM", StatsColors.ram, buildAnnotatedString { num("${gib(s?.ramUsedBytes)}/${gib(s?.ramTotalBytes)}", "GiB", z); gap(); num(s?.ramPercent?.roundToInt()?.toString() ?: "–", "%", z) }), + row("SWP", StatsColors.ram, buildAnnotatedString { num("${gib(s?.swapUsedBytes)}/${gib(s?.swapTotalBytes)}", "GiB", z) }), + row("NET", accent, buildAnnotatedString { + withStyle(SpanStyle(color = HudInk.dim)) { append("↓") }; num(s?.netDownBps?.let { (it / 1024).toString() } ?: "–", "", z) + append(" "); withStyle(SpanStyle(color = HudInk.dim)) { append("↑") }; num(s?.netUpBps?.let { (it / 1024).toString() } ?: "–", "KB/s", z) + }), + row("BAT", StatsColors.bat, buildAnnotatedString { + num(s?.battery?.percent?.toString() ?: "–", "%", z); gap(); temp(d, HudMetrics.TempSensor.BATTERY, s?.battery?.tempC, z); gap() + num(s?.battery?.watts?.takeIf { it > 0f }?.let { "%.1f".format(it) } ?: "–", "W", z) + }), + row("FAN", HudInk.dim, buildAnnotatedString { num(d.fanRpm?.toString() ?: "–", "rpm", z) }), + row(d.engine.ifEmpty { "FPS" }, accent, buildAnnotatedString { num(f1(d.fps), "FPS", z, accent); gap(); num(f1(d.frametimeMs), "ms", z) }), + row("AVG", HudInk.low, buildAnnotatedString { num(f1(d.avgFps), "FPS", z) }), + row("1%", HudInk.low, buildAnnotatedString { num(f1(d.lows.low1Fps), "FPS", z) }), + row("0.1%", HudInk.low, buildAnnotatedString { num(f1(d.lows.low01Fps), "FPS", z) }), + row("0.01%", HudInk.low, buildAnnotatedString { num(f1(d.lows.low001Fps), "FPS", z) }), + ) + Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) { + Rows(left, z, 44.dp, 3.dp) + Rows(right, z, 58.dp, 3.dp) + } + Spacer(Modifier.weight(1f)) + Line(buildAnnotatedString { + withStyle(SpanStyle(color = HudInk.dim)) { append("RES ") }; append(d.resolution.ifEmpty { "–" }) + d.proton?.let { withStyle(SpanStyle(color = HudInk.dim)) { append(" · ") }; withStyle(SpanStyle(color = HudInk.vram)) { append(it) } } + d.played?.let { withStyle(SpanStyle(color = HudInk.dim)) { append(" · ") }; append(it) } + }, 12.5.sp, Modifier.padding(bottom = 6.dp)) + FrameGraph(d, accent, 30.dp) +} diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StatsStrips.kt b/app/src/main/java/com/droiddeck/launcher/ui/StatsStrips.kt index 02bfee49d..1d2480688 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/StatsStrips.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/StatsStrips.kt @@ -36,18 +36,23 @@ 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.layout.Layout 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.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.sp import com.droiddeck.launcher.R import com.droiddeck.launcher.hud.DroidDeckMark +import com.droiddeck.launcher.hud.FrameStats import com.droiddeck.launcher.hud.HudMetrics import com.droiddeck.launcher.session.StatsStrip +import com.droiddeck.launcher.session.StripMetric import kotlin.math.max import kotlin.math.roundToInt @@ -66,11 +71,21 @@ internal object StatsColors { internal class History(private val capacity: Int) { private val values = ArrayDeque(capacity) - fun add(v: Float) { - if (values.size == capacity) values.removeFirst() + private val times = ArrayDeque(capacity) + fun add(v: Float, atMs: Long = 0L) { + if (values.size == capacity) { + values.removeFirst() + times.removeFirst() + } values.addLast(v) + times.addLast(atMs) } fun snapshot(): List = values.toList() + fun spanSeconds(): Int { + val first = times.firstOrNull()?.takeIf { it > 0L } ?: return 0 + val last = times.last() + return ((last - first + 500L) / 1000L).toInt() + } } private val MARK_RATIO = DroidDeckMark.widthFor(1f) @@ -144,6 +159,12 @@ internal class StripData( val wattsHistory: List, val clock: String, val game: String? = null, + val engine: String = "", + val lows: FrameStats.Lows? = null, + val graph: FloatArray? = null, + val metrics: Set = StripMetric.defaults, + val fahrenheit: Boolean = false, + val tempColor: ((HudMetrics.TempSensor, Int) -> Color)? = null, ) private fun percentTemp(percent: Int?, temp: Int?): String = @@ -158,6 +179,7 @@ private fun fpsText(fps: Float?): String = fps?.takeIf { it > 0f }?.let { "%.1f" internal fun StatsStripView(style: StatsStrip, d: StripData, onTap: () -> Unit) { Box(Modifier.fillMaxWidth().clickable(onClick = onTap)) { when (style) { + StatsStrip.LINE -> LineStrip(d) StatsStrip.PANEL -> PanelStrip(d) StatsStrip.TILES -> TilesStrip(d) StatsStrip.RINGS -> RingsStrip(d) @@ -165,6 +187,113 @@ internal fun StatsStripView(style: StatsStrip, d: StripData, onTap: () -> Unit) } } +private object LineInk { + val value = Color(0xFFF2F5F9) + val dim = Color(0xFF9AA4B2) + val vram = Color(0xFFC98BFF) +} + +private class LineItem(val priority: Int, val content: @Composable () -> Unit) + +@Composable +internal fun LineStrip(d: StripData, modifier: Modifier = Modifier) { + val pal = LocalPalette.current + val shape = RoundedCornerShape(12.dp) + val m = d.metrics + val s = d.device + val items = ArrayList() + items += LineItem(0) { DroidDeckLogo(16.dp) } + if (StripMetric.ENGINE in m && d.engine.isNotEmpty()) items += LineItem(70) { LineText(d.engine, LineInk.dim, 12.sp) } + items += LineItem(0) { + Row(verticalAlignment = Alignment.Bottom) { + LineText(fpsText(d.fps), LineInk.value, 17.sp) + LineText("fps", LineInk.dim, 10.sp, Modifier.padding(start = 2.dp, bottom = 2.dp)) + } + } + val graph = d.graph + if (StripMetric.GRAPH in m && graph != null) items += LineItem(60) { + Sparkline(graph.asList(), 0f, FrameStats.GRAPH_MAX_MS, lerp(pal.primary, Color.White, 0.3f), Modifier.width(56.dp).height(16.dp)) + } + if (StripMetric.LOWS in m) { + items += LineItem(10) { LinePair("1%", LineInk.dim, lowText(d.lows?.low1Fps), StatsColors.cpu) } + items += LineItem(90) { LinePair("0.1%", LineInk.dim, lowText(d.lows?.low01Fps), StatsColors.cpu) } + } + if (StripMetric.LOW001 in m) items += LineItem(100) { LinePair("0.01%", LineInk.dim, lowText(d.lows?.low001Fps), StatsColors.cpu) } + if (StripMetric.GPU in m || StripMetric.GPU_TEMP in m) items += LineItem(20) { + LineMetric("GPU", StatsColors.gpu, if (StripMetric.GPU in m) s?.gpuPercent else null, + if (StripMetric.GPU_TEMP in m) s?.gpuTempC else null, HudMetrics.TempSensor.GPU, d) + } + if (StripMetric.CPU in m || StripMetric.CPU_TEMP in m) items += LineItem(30) { + LineMetric("CPU", StatsColors.cpu, if (StripMetric.CPU in m) s?.cpuPercent else null, + if (StripMetric.CPU_TEMP in m) s?.cpuTempC else null, HudMetrics.TempSensor.CPU, d) + } + if (StripMetric.VRAM in m) items += LineItem(80) { LinePair("VRAM", LineInk.vram, gibText(s?.vramUsedBytes), LineInk.vram) } + if (StripMetric.RAM in m) items += LineItem(40) { LinePair("RAM", StatsColors.ram, s?.let { "${it.ramPercent.roundToInt()}%" } ?: "–", StatsColors.ram) } + val clock = StripMetric.CLOCK in m && d.clock.isNotEmpty() + if (clock) items += LineItem(50) { LineText(d.clock, LineInk.dim, 12.sp) } + PriorityRow( + IntArray(items.size) { items[it].priority }, 10.dp, pinLast = clock, + modifier = modifier.fillMaxWidth().clip(shape).background(pal.surface).border(1.dp, pal.primary.copy(alpha = 0.35f), shape) + .padding(horizontal = 10.dp, vertical = 6.dp), + ) { + items.forEach { it.content() } + } +} + +@Composable +private fun PriorityRow(priorities: IntArray, spacing: Dp, pinLast: Boolean, modifier: Modifier = Modifier, content: @Composable () -> Unit) { + Layout(content, modifier) { measurables, constraints -> + val gap = spacing.roundToPx() + val loose = Constraints(maxHeight = constraints.maxHeight) + val placeables = measurables.map { it.measure(loose) } + val max = if (constraints.hasBoundedWidth) constraints.maxWidth else Int.MAX_VALUE + val keep = fitByPriority(IntArray(placeables.size) { placeables[it].width }, priorities.copyOf(placeables.size), gap, max) + val height = maxOf(constraints.minHeight, placeables.filterIndexed { i, _ -> keep[i] }.maxOfOrNull { it.height } ?: 0) + val used = placeables.filterIndexed { i, _ -> keep[i] }.sumOf { it.width } + gap * maxOf(0, keep.count { it } - 1) + val width = if (constraints.hasBoundedWidth) constraints.maxWidth else used + layout(width, height) { + var x = 0 + val last = placeables.lastIndex + placeables.forEachIndexed { i, p -> + if (!keep[i]) return@forEachIndexed + val px = if (pinLast && i == last) width - p.width else x + p.placeRelative(px, (height - p.height) / 2) + x += p.width + gap + } + } + } +} + +@Composable +private fun LineText(text: String, color: Color, size: TextUnit = 13.sp, modifier: Modifier = Modifier) { + Text(text, fontSize = size, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace, color = color, maxLines = 1, softWrap = false, + modifier = modifier) +} + +@Composable +private fun LinePair(label: String, labelColor: Color, value: String, valueColor: Color) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + LineText(label, labelColor, 11.sp) + LineText(value, valueColor) + } +} + +@Composable +private fun LineMetric(label: String, color: Color, percent: Int?, tempC: Int?, sensor: HudMetrics.TempSensor, d: StripData) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + LineText(label, color, 11.sp) + if (percent == null && tempC == null) LineText("–", color) + if (percent != null) LineText("$percent%", color) + if (tempC != null) LineText(tempText(tempC, d.fahrenheit), d.tempColor?.invoke(sensor, tempC) ?: LineInk.value) + } +} + +private fun lowText(fps: Float?): String = fps?.takeIf { it > 0f }?.let { "%.1f".format(it) } ?: "–" + +private fun gibText(bytes: Long?): String = bytes?.let { "%.1fGiB".format(it / 1073741824.0) } ?: "–" + +private fun tempText(c: Int, fahrenheit: Boolean): String = if (fahrenheit) "${(c * 9f / 5f + 32f).roundToInt()}°F" else "$c°C" + @Composable private fun PanelStrip(d: StripData) { val pal = LocalPalette.current diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StripFit.kt b/app/src/main/java/com/droiddeck/launcher/ui/StripFit.kt new file mode 100644 index 000000000..fa4131ca8 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/ui/StripFit.kt @@ -0,0 +1,15 @@ +package com.droiddeck.launcher.ui + +internal fun fitByPriority(widths: IntArray, priorities: IntArray, spacing: Int, maxWidth: Int): BooleanArray { + val keep = BooleanArray(widths.size) + var used = 0 + var count = 0 + for (i in widths.indices.sortedWith(compareBy({ priorities[it] }, { it }))) { + val add = widths[i] + if (count > 0) spacing else 0 + if (used + add > maxWidth) break + keep[i] = true + used += add + count++ + } + return keep +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1c12b8283..5ba641e60 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1946,7 +1946,21 @@ Fuel, t Main %1$s t Reserve %1$s t - Tap the top strip or the dash to change its style. + Tap the HUD, the top strip or the dash to change its style. + Frame time + last %1$d s + avg frame + 1 percent low + frames over 33 ms + %1$d / min + Playing for %1$s + %1$d cores, up to %2$s GHz + %1$d cores + game + FPS of %1$d Hz + The red mark is where the HUD shows the temperature in red. + whole device + battery Send dashboard data Automatic Turns it on for every game listed here, including games found later. Games you switch off stay off. @@ -1970,6 +1984,13 @@ Mic is off for this game. To talk, turn on Microphone in DroidDeck\'s Steam settings (Audio) and start the game again. Volume %1$d%% In-game stats + Bottom screen HUD + Works with the top HUD off and does not change it. The number switches below are for the Line strip: under the tabs, and the Line style above a game\'s dashboard. + The big HUD, the Line strip and the strip under the tabs. A game\'s dashboard stays. + Strip on every tab + Shows the line under the tabs while you use the bottom screen. + 1% and 0.1% lows + CPU temperature FPS CPU GPU diff --git a/app/src/test/java/com/droiddeck/launcher/hud/FrameStatsTest.kt b/app/src/test/java/com/droiddeck/launcher/hud/FrameStatsTest.kt index 15656e6a8..724275fd3 100644 --- a/app/src/test/java/com/droiddeck/launcher/hud/FrameStatsTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/hud/FrameStatsTest.kt @@ -68,6 +68,30 @@ class FrameStatsTest { assertFalse(feed.poll(now + 1)) } + @Test fun slowFramesCountOnlyFramesOver33MsInTheWindow() { + val stats = FrameStats() + var now = 1_000_000_000L + for (ft in listOf(16_666_667L, 40_000_000L, 16_666_667L, 50_000_000L, 2_000_000_000L, 16_666_667L)) { + now += ft + stats.add(ft, now) + } + assertEquals(2, stats.slowFrames(now, 60_000_000_000L)) + assertEquals(0, stats.slowFrames(now + 120_000_000_000L, 60_000_000_000L)) + stats.reset() + assertEquals(0, stats.slowFrames(now, 60_000_000_000L)) + } + + @Test fun compositorFramesDoNotCountAsLiveGamescopeFrames() { + val feed = GameFrameFeed(java.io.File("/nonexistent/droiddeck-hud/frames")) + var now = 1_000_000_000L + assertFalse(feed.live) + feed.onCompositorFrame(now) + now += 16_666_667L + feed.onCompositorFrame(now) + feed.poll(now) + assertFalse(feed.live) + } + @Test fun aCompletedPeriodIsCountedWhenMangoHudWouldRedraw() { val stats = FrameStats() var now = 1_000_000_000L diff --git a/app/src/test/java/com/droiddeck/launcher/session/StatsLayoutTest.kt b/app/src/test/java/com/droiddeck/launcher/session/StatsLayoutTest.kt index 636e7e439..8d2f97ae3 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/StatsLayoutTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/StatsLayoutTest.kt @@ -5,16 +5,30 @@ import org.junit.Test class StatsLayoutTest { @Test fun unknownIdsFallBackToTheDefaults() { - assertEquals(StatsStrip.PANEL, StatsStrip.from(null)) + assertEquals(StatsStrip.LINE, StatsStrip.from(null)) + assertEquals(StatsStrip.LINE, StatsStrip.from("gone")) + assertEquals(StatsStrip.PANEL, StatsStrip.from("panel")) assertEquals(StatsStrip.RINGS, StatsStrip.from("rings")) assertEquals(StatsDash.AUTO, StatsDash.from("gone")) assertEquals(StatsDash.TRACE, StatsDash.from("trace")) } @Test fun tappingTheStripCyclesThroughEveryStyle() { + assertEquals(StatsStrip.PANEL, StatsStrip.LINE.next()) assertEquals(StatsStrip.TILES, StatsStrip.PANEL.next()) assertEquals(StatsStrip.RINGS, StatsStrip.TILES.next()) - assertEquals(StatsStrip.PANEL, StatsStrip.RINGS.next()) + assertEquals(StatsStrip.LINE, StatsStrip.RINGS.next()) + } + + @Test fun stripNumbersStartWithEverythingButTheGpuTemperature() { + assertEquals(StripMetric.entries.toSet() - StripMetric.GPU_TEMP, StripMetric.parse(null)) + } + + @Test fun stripNumbersSurviveSavingAndSkipUnknownIds() { + val picked = setOf(StripMetric.GPU, StripMetric.RAM, StripMetric.CLOCK) + assertEquals(picked, StripMetric.parse(StripMetric.encode(picked))) + assertEquals(setOf(StripMetric.CPU), StripMetric.parse("cpu,gone, ")) + assertEquals(emptySet(), StripMetric.parse("")) } @Test fun autoPicksTheDashByTheGamesFormat() { diff --git a/app/src/test/java/com/droiddeck/launcher/ui/HistoryTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/HistoryTest.kt new file mode 100644 index 000000000..0e4fef58d --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/HistoryTest.kt @@ -0,0 +1,33 @@ +package com.droiddeck.launcher.ui + +import org.junit.Assert.assertEquals +import org.junit.Test + +class HistoryTest { + @Test fun theSpanIsTheTimeTheKeptReadingsCover() { + val h = History(3) + h.add(1f, 1_000L) + h.add(2f, 1_500L) + assertEquals(1, h.spanSeconds()) + h.add(3f, 2_000L) + h.add(4f, 4_000L) + assertEquals(listOf(2f, 3f, 4f), h.snapshot()) + assertEquals(3, h.spanSeconds()) + } + + @Test fun aHistoryThatStopsBeingFedKeepsItsSpan() { + val h = History(4) + h.add(1f, 10_000L) + h.add(2f, 20_000L) + assertEquals(10, h.spanSeconds()) + assertEquals(10, h.spanSeconds()) + } + + @Test fun readingsWithoutATimeHaveNoSpan() { + val h = History(2) + h.add(1f) + assertEquals(0, h.spanSeconds()) + assertEquals(0, History(2).spanSeconds()) + assertEquals(0, History(2).apply { add(1f, 5_000L) }.spanSeconds()) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/StripFitTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/StripFitTest.kt new file mode 100644 index 000000000..6d9aed0a1 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/StripFitTest.kt @@ -0,0 +1,26 @@ +package com.droiddeck.launcher.ui + +import org.junit.Assert.assertArrayEquals +import org.junit.Test + +class StripFitTest { + @Test fun everythingFitsOnAWideLine() { + assertArrayEquals(booleanArrayOf(true, true, true), fitByPriority(intArrayOf(10, 20, 30), intArrayOf(0, 1, 2), 5, 100)) + } + + @Test fun theLeastImportantItemsAreLeftOutFirst() { + assertArrayEquals(booleanArrayOf(true, false, true), fitByPriority(intArrayOf(10, 20, 30), intArrayOf(0, 2, 1), 5, 60)) + } + + @Test fun anItemThatDoesNotFitEndsTheLine() { + assertArrayEquals(booleanArrayOf(true, false, false), fitByPriority(intArrayOf(10, 50, 5), intArrayOf(0, 1, 2), 2, 40)) + } + + @Test fun equalPrioritiesKeepTheirOrder() { + assertArrayEquals(booleanArrayOf(true, true, false), fitByPriority(intArrayOf(10, 10, 10), intArrayOf(0, 0, 0), 0, 25)) + } + + @Test fun nothingFitsInNoRoom() { + assertArrayEquals(booleanArrayOf(false, false), fitByPriority(intArrayOf(10, 10), intArrayOf(0, 1), 0, 5)) + } +}