App v0.2.26 and gateway 0.1.20

Client: seek controls, Bazarr subtitle download and cast panel in the
player; MDBList ratings strip; episode and schedule detail pages; series
pace estimate; what's new panel; install-permission onboarding step;
synced per-profile preferences; Emby outage banner.

Gateway: rebuilt admin console (one fragment per page), preference
history and restore, merged Continue Watching, Emby health probe,
subtitle selection and Bazarr download, structured request logging with
per-request identity, and embedded build version.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ponzischeme89
2026-08-06 22:33:56 +12:00
co-authored by Claude Opus 5
parent 2675e6d82b
commit 4a4df7a73c
257 changed files with 24868 additions and 3108 deletions
@@ -0,0 +1,194 @@
package com.ponzischeme89.memby.benchmark
import android.view.KeyEvent
import androidx.benchmark.macro.CompilationMode
import androidx.benchmark.macro.ExperimentalMetricApi
import androidx.benchmark.macro.MacrobenchmarkScope
import androidx.benchmark.macro.StartupMode
import androidx.benchmark.macro.TraceSectionMetric
import androidx.benchmark.macro.junit4.MacrobenchmarkRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.uiautomator.By
import androidx.test.uiautomator.Until
import org.junit.Assume.assumeTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* How long a viewer waits between pressing Play and seeing a picture.
*
* This exists because the question could not be settled by hand. Timing a few launches off
* the logcat line gave a 70% spread between identical runs of the same file — enough noise
* to make a single before-and-after comparison meaningless, and enough to let a real
* regression hide. Repeating a launch ten times and reporting the distribution is the only
* way to know whether a change to the player's HTTP stack, its buffering or the order of
* its startup actually moved anything.
*
* Two metrics, from the spans in `PlaybackTraceSections`:
*
* - `Memby.playbackLaunch` — the whole wait, from the player opening to the first frame.
* - `Memby.playbackFirstFrame` — `prepare()` to the first frame: opening the file over
* HTTP, seeking, and starting a decoder. On a resume this is the overwhelming majority
* of the wait, so a change that moves the first number without moving this one has
* almost certainly moved something else.
*
* **This benchmark talks to a real server.** Unlike `HomeBenchmark`, it cannot run against
* a blank install: the television must be signed in and the Emby server reachable, because
* what is being measured is largely the network and the decoder. That also means the
* numbers are only comparable against other runs on *the same TV, server and title* — they
* are a regression guard, not an absolute.
*
* Preconditions, all of which fail the run loudly rather than quietly measuring nothing:
*
* 1. Memby signed in, on the Home destination.
* 2. At least one part-watched item in Continue Watching, so a detail page offers Resume.
*
* Run it with the variant task, not `connectedCheck` — the latter also runs
* `nonMinifiedRelease`, which is the baseline-profile generator's variant, and would
* measure everything twice:
*
* ```
* $env:JAVA_HOME = "C:\Program Files\Android\Android Studio\jbr"
* .\gradlew.bat :benchmark:connectedBenchmarkReleaseAndroidTest `
* -P android.testInstrumentationRunnerArguments.class=com.ponzischeme89.memby.benchmark.PlaybackBenchmark
* ```
*/
@OptIn(ExperimentalMetricApi::class)
@RunWith(AndroidJUnit4::class)
class PlaybackBenchmark {
@get:Rule val benchmarkRule = MacrobenchmarkRule()
/**
* The case this was all built for: picking something up part-way through.
*
* The browsing is deliberately in `setupBlock` and therefore unmeasured. What is timed
* is one keypress on Resume and the wait that follows it, which is the thing a viewer
* is actually sitting through.
*/
@Test
fun resumeFromContinueWatching() = benchmarkRule.measureRepeated(
packageName = PACKAGE_NAME,
metrics = listOf(
TraceSectionMetric(PLAYBACK_LAUNCH, TraceSectionMetric.Mode.Sum),
TraceSectionMetric(PLAYBACK_FIRST_FRAME, TraceSectionMetric.Mode.Sum),
),
compilationMode = CompilationMode.Partial(),
startupMode = StartupMode.WARM,
iterations = ITERATIONS,
setupBlock = {
pressHome()
startActivityAndWait()
openResumableDetailPage()
},
measureBlock = { playAndWaitForFirstFrame() },
)
/**
* The same measurement without a seek, as the control. A resume differs from a cold
* start in exactly one way that matters — the file is opened part-way through — so the
* pair is what says whether a change helped seeking or helped everything.
*
* The hero's Play button is used because it is the one target on the launcher that is
* always exactly one D-pad press away, which keeps this from measuring navigation luck.
*/
@Test
fun coldStartFromHomeHero() = benchmarkRule.measureRepeated(
packageName = PACKAGE_NAME,
metrics = listOf(
TraceSectionMetric(PLAYBACK_LAUNCH, TraceSectionMetric.Mode.Sum),
TraceSectionMetric(PLAYBACK_FIRST_FRAME, TraceSectionMetric.Mode.Sum),
),
compilationMode = CompilationMode.Partial(),
startupMode = StartupMode.WARM,
iterations = ITERATIONS,
setupBlock = {
pressHome()
startActivityAndWait()
device.pressKeyCode(KeyEvent.KEYCODE_DPAD_RIGHT)
device.waitForIdle()
},
measureBlock = { playAndWaitForFirstFrame() },
)
/**
* Presses the focused Play button and waits for the picture.
*
* The end of the wait is the loading overlay going away, which `PlayerActivity` does
* from `onRenderedFirstFrame` — the same moment that closes the trace spans. The wait
* is only here to stop the capture ending mid-launch; the spans, not this, are what is
* reported.
*/
private fun MacrobenchmarkScope.playAndWaitForFirstFrame() {
device.pressKeyCode(KeyEvent.KEYCODE_DPAD_CENTER)
val started = device.wait(
Until.hasObject(By.res(PACKAGE_NAME, PLAYER_LOADING_ID)),
PLAYER_APPEAR_TIMEOUT_MS,
)
// A launch that reused a prefetched stream can be past the overlay before the first
// poll, so its absence is not a failure — only never getting a picture is.
if (started != null) {
check(
device.wait(
Until.gone(By.res(PACKAGE_NAME, PLAYER_LOADING_ID)),
FIRST_FRAME_TIMEOUT_MS,
),
) { "No picture within ${FIRST_FRAME_TIMEOUT_MS}ms — is the server reachable?" }
}
device.waitForIdle()
}
/**
* Walks the Continue Watching row until a detail page offers Resume, and leaves it
* open with that button focused.
*
* It searches rather than assuming a position because the row reorders itself as the
* household watches things — the item that was second yesterday is first today. If
* nothing in the row offers a Resume the run is skipped with a message, rather than
* silently measuring a cold start and reporting it as a resume.
*/
private fun MacrobenchmarkScope.openResumableDetailPage() {
device.pressKeyCode(KeyEvent.KEYCODE_DPAD_RIGHT)
repeat(2) { device.pressKeyCode(KeyEvent.KEYCODE_DPAD_DOWN) }
device.waitForIdle()
repeat(CARDS_TO_TRY) {
device.pressKeyCode(KeyEvent.KEYCODE_DPAD_CENTER)
if (device.wait(Until.hasObject(By.text(RESUME_LABEL)), DETAIL_TIMEOUT_MS) != null) {
device.waitForIdle()
return
}
// Not a resumable item. Close the detail page and try the next card along.
device.pressBack()
device.wait(Until.gone(By.text(RESUME_LABEL)), DETAIL_TIMEOUT_MS)
device.pressKeyCode(KeyEvent.KEYCODE_DPAD_RIGHT)
device.waitForIdle()
}
assumeTrue(
"No part-watched item found in Continue Watching — this benchmark needs one.",
false,
)
}
private companion object {
const val PACKAGE_NAME = "com.ponzischeme89.memby"
// Kept identical to PlaybackTraceSections in :app. They cannot be imported — the
// benchmark is a separate module that does not depend on the app's code — so a
// rename there has to be repeated here, and the assertion in
// PlaybackTraceNamesTest is what makes that impossible to forget.
const val PLAYBACK_LAUNCH = "Memby.playbackLaunch"
const val PLAYBACK_FIRST_FRAME = "Memby.playbackFirstFrame"
const val PLAYER_LOADING_ID = "playback_loading"
const val RESUME_LABEL = "Resume"
/** Ten launches is enough to see the distribution through the run-to-run spread. */
const val ITERATIONS = 10
const val CARDS_TO_TRY = 6
const val PLAYER_APPEAR_TIMEOUT_MS = 3_000L
const val DETAIL_TIMEOUT_MS = 5_000L
const val FIRST_FRAME_TIMEOUT_MS = 30_000L
}
}