From af1ca4370ccdfd94adba5183dbef27dddc6c9a38 Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Fri, 21 Aug 2026 10:38:41 +1200 Subject: [PATCH] 0.2.85 --- HOME_PERFORMANCE.md | 70 +++++++++++++++++++ app/build.gradle.kts | 5 +- .../ponzischeme89/memby/ui/HomeComponents.kt | 6 +- .../com/ponzischeme89/memby/ui/HomeScreen.kt | 5 +- .../ponzischeme89/memby/ui/LaunchPreroll.kt | 2 +- .../memby/ui/player/PrerollPreloader.kt | 2 +- .../ponzischeme89/memby/ui/HomeUiStateTest.kt | 1 + .../memby/ui/ResumableMediaCardTest.kt | 5 +- .../memby/benchmark/HomeBenchmark.kt | 26 +++++-- 9 files changed, 107 insertions(+), 15 deletions(-) create mode 100644 HOME_PERFORMANCE.md diff --git a/HOME_PERFORMANCE.md b/HOME_PERFORMANCE.md new file mode 100644 index 0000000..10253f0 --- /dev/null +++ b/HOME_PERFORMANCE.md @@ -0,0 +1,70 @@ +# Home performance baseline + +This is the repeatable performance check for Home. The source baseline is the current +good-performing release line at commit `5467fba` (0.2.84). Numbers are only comparable on +the same television, Android build, server, network and signed-in profile, so benchmark +results belong with the test run or release notes rather than as universal targets here. + +## What is measured + +- `coldStartToHomeNoCompilation`: cold launch without ahead-of-time compilation, used as + the control for the baseline profile. +- `coldStartToHomeWithProfile`: cold launch as a release is shipped. `timeToFullDisplayMs` + ends when Home reports its first D-pad-ready, settled frame. +- `warmStartToHomeWithProfile`: returning to a resident process. +- `homeDpadAndRows`: frame timing during rapid travel across cards, down four rows, and back + through already displayed artwork. The route deliberately does not idle between presses. +- Debug milestones in `MembyStartup`: process start to `home_visible`, + `first_row_visible`, and `home_interactive`. +- Debug frame windows in `EmbyClientPerf`: jank percentage, p50/p95/p99 UI frame time, + frames over 16 ms and 33 ms, and the maximum frame time. + +Compose Layout Inspector supplies card and row recomposition counts. Capture a system trace +at the same time when a frame regression needs attribution; the trace shows Compose work, +Coil fetch/decode activity and main-thread scheduling together. + +## Run protocol + +Use a physical TV with a signed-in profile and a populated Home cache. Keep the TV's display +mode, server, network and Home row preferences unchanged between the reference and candidate +runs. + +```powershell +$env:JAVA_HOME = "C:\Program Files\Android\Android Studio\jbr" +.\gradlew.bat :benchmark:connectedBenchmarkReleaseAndroidTest ` + -P android.testInstrumentationRunnerArguments.class=com.ponzischeme89.memby.benchmark.HomeBenchmark +``` + +For an interactive debug capture, clear logcat, cold-start Memby, wait for Home, then rapidly +hold Right and move through at least four rows. Record: + +```powershell +adb logcat -s MembyStartup EmbyClientPerf +``` + +In Layout Inspector, enable recomposition counts and repeat the same route. Unchanged cards +outside the old and new focus targets should normally be skipped; focus animation should not +cause per-frame card recomposition. In Android Studio's system trace, inspect the slowest +frames for main-thread Compose work and Coil image fetch/decode slices. + +## Regression gates + +Use the median of at least five iterations. Investigate before release when, against a fresh +reference run on the same setup: + +- profiled cold-start `timeToFullDisplayMs` or warm start regresses by more than 10%; +- `frameDurationCpuMs` p95 regresses by more than 10%, or p99 crosses 33 ms; +- a previously displayed card causes a network fetch or another decode on the return pass; +- focus animation recomposes a card on every animation frame or invalidates unaffected cards; +- adding a Home row materially changes first-row visibility time. Below-the-fold rows may + affect `home_interactive`, but must not delay `first_row_visible`. + +Regenerate the baseline profile after changing the startup or first-browse path: + +```powershell +.\gradlew.bat :app:generateReleaseBaselineProfile +``` + +Keep the reference and candidate benchmark result directories together. Hardware variance +makes the paired result the baseline; a number copied from a different TV is not evidence of +a regression or an improvement. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 982bab5..7b71b9f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,4 @@ +import com.android.build.api.dsl.ApplicationExtension import java.util.Properties plugins { @@ -63,7 +64,7 @@ val projectNoticeText = // A release workflow can derive the app version from its Git tag without editing the // source tree. Local builds keep using the checked-in default. -val defaultVersionName = "0.2.84" +val defaultVersionName = "0.2.85" val membyVersionName: String = (project.findProperty("memby.versionName") as String?) ?.trim() @@ -95,7 +96,7 @@ fun environmentSecret(key: String): String? { return System.getenv(key)?.trim()?.takeIf { it.isNotEmpty() } } -android { +extensions.configure { namespace = "com.ponzischeme89.memby" // Raised to 37 by the Lifecycle 2.11 / Activity 1.13 upgrade, which refuse to be // consumed by a project compiled against anything older. This is a *compile* target diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt index dfeae08..c3df175 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeComponents.kt @@ -1687,7 +1687,7 @@ internal fun MediaRow( onVerticalFocusRequestConsumed: (Int) -> Unit, onMoveVertical: (itemIndex: Int, direction: RowFocusDirection) -> Boolean, onContentFocused: () -> Unit, - onItemFocused: (BaseItem) -> Unit, + onItemFocused: (item: BaseItem, itemIndex: Int) -> Unit, onItemSelected: (BaseItem) -> Unit, onItemLongPressed: (BaseItem) -> Unit, density: String = "standard", @@ -1840,7 +1840,9 @@ internal fun MediaRow( } val focused: () -> Unit = { onContentFocused() - onItemFocused(item) + // LazyRow already knows the semantic position. Passing it on + // avoids searching the row again on every D-pad focus move. + onItemFocused(item, index) } val format = cardFormat(row.kind, item, artworkStyle) if (row.kind == MediaRowKind.CONTINUE) { diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt index 807b6ba..7dcb4c6 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt @@ -1593,9 +1593,8 @@ internal fun HomeScreen( true }, onContentFocused = { navigationExpanded = false }, - onItemFocused = { item -> - val itemIndex = row.items.indexOfFirst { it.id == item.id } - if (itemIndex >= 0) rowFocusPositions[row.id] = itemIndex + onItemFocused = { item, itemIndex -> + rowFocusPositions[row.id] = itemIndex rowFocusedBelowHero = true if (selectedDestination == BrowseDestination.HOME) { focusedHomeRowId = row.id diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/LaunchPreroll.kt b/app/src/main/java/com/ponzischeme89/memby/ui/LaunchPreroll.kt index 3c2e50e..db31dd1 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/LaunchPreroll.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/LaunchPreroll.kt @@ -77,7 +77,7 @@ internal object LaunchIntro { * - **The player is returned, not released.** It goes back to the process cache on dispose, * which is what lets the very next playback still open on a prepared instance. */ -@OptIn(UnstableApi::class) +@androidx.annotation.OptIn(markerClass = [UnstableApi::class]) @Composable internal fun LaunchPrerollVideo( modifier: Modifier = Modifier, diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/player/PrerollPreloader.kt b/app/src/main/java/com/ponzischeme89/memby/ui/player/PrerollPreloader.kt index f989ba8..b69575f 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/player/PrerollPreloader.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/player/PrerollPreloader.kt @@ -19,7 +19,7 @@ import com.ponzischeme89.memby.R * sought back to the beginning and prepared again after use rather than reconstructed for * every title. */ -@OptIn(UnstableApi::class) +@androidx.annotation.OptIn(markerClass = [UnstableApi::class]) internal object PrerollPreloader { private val mainHandler = Handler(Looper.getMainLooper()) private var applicationContext: Context? = null diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/HomeUiStateTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/HomeUiStateTest.kt index 684db37..0e595e5 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/HomeUiStateTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/HomeUiStateTest.kt @@ -1,6 +1,7 @@ package com.ponzischeme89.memby.ui import com.ponzischeme89.memby.data.HomeCache +import com.ponzischeme89.memby.data.PlaybackPosition import com.ponzischeme89.memby.data.model.BaseItem import com.ponzischeme89.memby.data.model.HomeRow import com.ponzischeme89.memby.data.model.UserItemData diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/ResumableMediaCardTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/ResumableMediaCardTest.kt index aaafceb..0f7c0f8 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/ResumableMediaCardTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/ResumableMediaCardTest.kt @@ -36,8 +36,8 @@ class ResumableMediaCardTest { @Test fun finishTimeUsesRemainingRuntime() { val now = 1_000_000L - val runtime = 60L * 60L * TICKS_PER_MILLISECOND_FOR_TEST - val position = 15L * 60L * TICKS_PER_MILLISECOND_FOR_TEST + val runtime = 60L * 60L * MILLIS_PER_SECOND * TICKS_PER_MILLISECOND_FOR_TEST + val position = 15L * 60L * MILLIS_PER_SECOND * TICKS_PER_MILLISECOND_FOR_TEST assertEquals( now + 45L * 60L * 1_000L, @@ -61,6 +61,7 @@ class ResumableMediaCardTest { } private companion object { + const val MILLIS_PER_SECOND = 1_000L const val TICKS_PER_MILLISECOND_FOR_TEST = 10_000L } } diff --git a/benchmark/src/main/java/com/ponzischeme89/memby/benchmark/HomeBenchmark.kt b/benchmark/src/main/java/com/ponzischeme89/memby/benchmark/HomeBenchmark.kt index eb13bc7..13fb7b7 100644 --- a/benchmark/src/main/java/com/ponzischeme89/memby/benchmark/HomeBenchmark.kt +++ b/benchmark/src/main/java/com/ponzischeme89/memby/benchmark/HomeBenchmark.kt @@ -64,14 +64,32 @@ class HomeBenchmark { ) private fun MacrobenchmarkScope.exerciseHome() { - device.pressKeyCode(android.view.KeyEvent.KEYCODE_DPAD_RIGHT) - device.pressKeyCode(android.view.KeyEvent.KEYCODE_DPAD_RIGHT) - device.pressKeyCode(android.view.KeyEvent.KEYCODE_DPAD_DOWN) - device.pressKeyCode(android.view.KeyEvent.KEYCODE_DPAD_RIGHT) + // Do not wait between presses: a real performance problem is most visible while a + // viewer holds the remote and focus animations, lazy composition and image decode + // overlap. Travelling both ways also covers cached artwork and item reuse rather + // than measuring only the first decode of each card. + repeat(HORIZONTAL_PRESSES) { + device.pressKeyCode(android.view.KeyEvent.KEYCODE_DPAD_RIGHT) + } + repeat(VERTICAL_PRESSES) { + device.pressKeyCode(android.view.KeyEvent.KEYCODE_DPAD_DOWN) + repeat(HORIZONTAL_PRESSES_PER_ROW) { + device.pressKeyCode(android.view.KeyEvent.KEYCODE_DPAD_RIGHT) + } + } + repeat(VERTICAL_PRESSES) { + device.pressKeyCode(android.view.KeyEvent.KEYCODE_DPAD_UP) + repeat(HORIZONTAL_PRESSES_PER_ROW) { + device.pressKeyCode(android.view.KeyEvent.KEYCODE_DPAD_LEFT) + } + } device.waitForIdle() } private companion object { const val PACKAGE_NAME = "com.ponzischeme89.memby" + const val HORIZONTAL_PRESSES = 12 + const val HORIZONTAL_PRESSES_PER_ROW = 6 + const val VERTICAL_PRESSES = 4 } }