# Performance: the Wholphin review, and what came of it An engineering note on how Memby's performance work compares with [Wholphin](https://github.com/damontecres/Wholphin), which techniques were taken from it, and what the licence requires. It is a record of decisions, not a tutorial — the reasoning behind each individual mechanism lives beside the code in `CLAUDE.md`. ## Licensing position Memby is GPL-2.0. Wholphin is GPL-2.0. Adapting Wholphin source into Memby is therefore permitted outright, provided the combined work remains under GPLv2 and attribution is preserved. Both obligations are met in `NOTICE`, which names Wholphin, names the Memby files adapted from it, records that Memby's modifications are its own, and notes that Wholphin's own profiling code descends from Jellyfin Android TV. Two practical rules were followed: - **No verbatim copying.** Every technique below was reimplemented against Memby's own architecture. That is a deliberate choice rather than a licence requirement — Memby's repository is dual-path (gateway and direct-to-Emby) and its player is a single `PlayerActivity` with a pre-roll, credits pane and trailer chain, so ported code would have needed more reshaping than rewriting. - **Attribution attaches to techniques, not only to code.** `PlayerEngine.kt` and `TrickplayPreview.kt` carry in-file attribution because their *design* is Wholphin's even though the source is not. Anything reproduced independently is marked below so the distinction stays legible. ## Comparison Wholphin's speed comes from a small number of decisions rather than from micro-optimisation, and Memby had already absorbed most of them before this review: | Technique | Wholphin | Memby | | --- | --- | --- | | One shared OkHttp connection pool across API, artwork and media | yes | yes — `data/remote/HttpStack.kt` | | Media bytes pulled through the app's own HTTP client | yes | yes — `PlayerEngine.streamClient`, adapted | | Constant-bitrate seeking for containers with no usable seek table | yes | yes — `PlayerEngine.extractorsFactory`, adapted | | Seek-preview thumbnails from a server-side index | tile sheets | BIF, independently implemented — `data/Trickplay.kt` | | Cached home rendered before the network answers | yes | yes — `HomeCache`, per profile | | Server-side artwork resizing rather than full-resolution downloads | yes | yes — `maxWidth`/`quality` on both paths | | Request de-duplication and single-flighting | yes | yes — repository-wide, including focus prefetches | | Speculative resolution while a card is focused | partial | yes — two-wave warm, metadata then detail page | | Pre-opened connection to the media host | n/a (same host as API) | **new, see below** | The last row is the one real divergence, and it is architectural rather than a matter of discipline. In Wholphin the API and the media stream are the same Jellyfin host, so the connection is already warm by the time anybody presses Play. In Memby's gateway mode they are two different machines: everything except video goes to the gateway, and video direct-plays from Emby. Wholphin gets that warm connection for free; Memby had to be told to open one. ## Bottleneck identified Memby's own measurements (Chromecast with Google TV, NAS gateway, `MembyPlayback` trace) had already established the shape of a resume: `prepare()` → first frame is over 90% of it, stream negotiation ~120 ms, app startup ~365 ms. Within that, the largest single term was cold versus warm connection to Emby — **the same file at the same seek position took 4177 ms on the first playback of a session and 2004 ms on the second.** The first playback pays DNS, the TCP handshake, the TLS handshake and Emby's file open, none of which the second pays. Two things that look like causes and are not: subtitle auto-selection costs 20–200 ms, and the seek itself about 900 ms. ## What was implemented **`data/remote/StreamWarmer.kt`** — opens a connection to the machine video comes from before any video is requested. It works only because of the shared pool: a connection opened by an unrelated HEAD request is the one ExoPlayer picks up later, so nothing is handed over but the address. The design constraints that shaped it are recorded in `CLAUDE.md`; in brief, it warms the *host* and never the title (warming the film's first byte would put a delivery for a title nobody watched into somebody's server history, which is the same objection that stops this app warming `PlaybackInfo` on focus); it remembers the address across process restarts, since on the gateway path a resolved stream URL is the only thing that ever names Emby and the slowest playback was always the first one after a cold start; it is warmed both at application start and when card focus settles on a playable item; and every failure is silent, because it is a hint whose worst case is the behaviour that existed before it. This is reproduced independently. Wholphin has no equivalent — it does not have the problem. **`performance/StartupTrace.kt`** — the launch and navigation timings the playback trace did not cover: `home_visible`, `first_row_visible`, `home_interactive` as cumulative launch milestones, and `detail_visible` as a repeatable span. Debug-only, and every entry point returns before allocating on a release build. Milestones are recorded from `LaunchedEffect`s rather than composable bodies, so measuring the launcher cannot itself be a reason the launcher recomposes. **`PlaybackTrace.AUDIO_START`** — recorded from a media3 `AnalyticsListener`, which is the only interface reporting the moment the audio position genuinely begins advancing. It is worth having beside `first_frame` rather than inferred from it: a bitstreamed surround track waits on the receiver to lock to the format, and a track with no hardware decoder falls back to software. Either leaves a picture running silently for a moment, which a viewer reads as broken rather than slow, and which no video-side mark would show. ## What was reviewed and deliberately left alone Most of the brief's checklist was already satisfied, and saying so is more useful than changing working code: - **Artwork** is already resized server-side (`maxWidth` and `quality` on both the gateway proxy and direct Emby paths), cached in a memory cache sized at 25% and a 128 MB disk cache, decoded to hardware bitmaps, and fetched over the shared pool with crossfade off. - **Home startup** already renders `HomeCache` before the network answers, decodes that cache off the main thread, skips unchanged writes, and refreshes sections in parallel. - **Compose** is already disciplined in the ways that matter here: `HomeViewModel` exposes three `distinctUntilChanged` projections rather than one state object, animated values are read only in draw and layout lambdas, and lists deduplicate keys rather than folding in an index. - **Request cancellation and de-duplication** are already repository-wide, with focus prefetches single-flighted on the repository's own scope precisely so a cancelled prefetch does not abort the request a press is about to want. - **Returning from playback** already restores tab, season, grid offset and focused band via `ui/detail/DetailPosition.kt`, and deliberately re-requests the item because the episode just watched is now watched. No obsolete workaround was found that the new work makes redundant; the connection warm adds a capability rather than replacing a compensation. ## Measuring it ``` adb logcat -s MembyStartup MembyPlayback ``` `MembyStartup` prints launch milestones as `stage=cumulative(+delta)` and detail spans as `detail_visible=Nms`. `MembyPlayback` prints one `event=first_frame` line per launch carrying the full stage breakdown. The connection warm is a network effect and cannot be measured by a unit test. The honest comparison is first-playback-of-a-session against second, on one television, server and title — the pairing the original 4177/2004 ms figure came from. `:benchmark`'s `PlaybackBenchmark` measures the same span off trace sections when a signed-in television is available; it needs a real Emby, which is why it is excluded from ordinary CI.