On 15 September 2026 the second commit in the Yonto Player repo was called "Add source-agnostic content layer with adapter pattern and unit tests". It came about an hour after the initial commit. Its message says the UI and view models depend only on a repository and the domain models, never on a concrete provider, and that a new integration is added by implementing the same interface. Seven weeks later that sentence is still true, and it is the rule the rest of the app leans on.

The first post in this series sketched the interface in three calls. This one goes underneath: what the models look like, how failure is spelled, what the interface costs, and how it makes the app easy to test.

What the screens are allowed to know

Everything above the interface works in a small set of plain Kotlin data classes: a category, a summary (a card), a detail (a title page), a playback option, a stream. None of them says where it came from. The shape of a card, simplified:

// Simplified sketch, not the real file. data class MediaSummary( val id: String, // the source's own id, opaque to the app val sourceId: String, val title: String, val type: MediaType = MediaType.MOVIE, val posterUrl: String? = null, val year: String? = null, ) 

Almost everything past title is optional with a default, because sources know different amounts. A media server knows cast, ratings and episode lists, and a folder of files knows a file name. The models say "I may not know this" in the type, so a screen has to handle the absence once, in one place, rather than every screen branching on the kind of source.

The one that took the most thought is how a title says what to play. An option's source is a sealed type with four cases: a plain address that plays as it stands, a share the app opens itself, an opaque string only the source that issued it can redeem, and a file in the viewer's own storage. A sealed type makes every place that reads one decide what it does with each case, once, and the compiler checks that none was forgotten. The opaque string is the interesting case: the app hands it back to the source verbatim and never looks inside, because what it means is the source's business.

Failure is part of the contract

Almost every call returns Result<T>, and failures are a sealed hierarchy, not strings. A source can say a title was not found, that the login was refused, that it is unavailable, that it has no search, or that it is misconfigured, and each can carry the sentence the viewer should read. The screen picks wording and a next step from the type, for example offering to sign in again for a refused login, and never has to parse a message to find out what happened. That is also what lets the layer above treat a source that cannot be reached as a state it designed for.

What it costs

A black box has to fit very different things behind it. The price is that anything not every source can do has to be an optional call, and an optional call needs a default that is honest. The interface today has 19 members. Seven of them are required: the id and display name, categories, filters, a listing, a detail and search. The other twelve have defaults, for example:

// Simplified sketch of the optional calls. suspend fun getRecommendations(): Result<List<MediaSummary>> = Result.success(emptyList()) // none: Home falls back to the listings suspend fun reportPlayback(report: PlaybackReport): Result<Unit> = Result.success(Unit) // does nothing by default suspend fun getStream(token: String): Result<PlaybackStream> = Result.failure(ContentSourceException.Unavailable(sourceId)) 

Each default is chosen so that a source which ignores the call still behaves. Recommendations default to empty, and the app then builds Home from the listings. Reporting playback does nothing by default. The health check defaults to "can I fetch the categories", which is all most sources can say about themselves. The default that matters here is the one for redeeming an opaque string, which refuses: a source that emits those and does not implement it is contradicting itself, so the viewer is told rather than left waiting.

The real cost is discipline. Adding a capability means adding a call, a default and a test that the default is honest, and resisting the pull to let a screen peek at one source's quirks.

One door for the screens

Between the screens and an adapter there is a repository that owns what is the same for every source. It handles caching, falling back to the last good answer when a refresh fails, and validation. Categories are kept for a day and listings for fifteen minutes, as set in the caching design of the first week. An adapter stays small because the cross-cutting parts are not its problem.

The viewer's own files use the same door

The app also plays files that are not behind any server: a folder on the device, a NAS share, a USB drive. Nothing about that bypasses the rule. A files source is another adapter, built from a different branch of the same sealed source configuration, and it answers the same calls. It scans and groups file names into films, series and other videos, and then answers the same categories and listings as a media server would. So Home, Search and the title page read it like any other source, and the files and NAS guide is about setup, not a different app. Most sources are plugins run on a JavaScript engine, behind the very same interface, which the plugin reference describes.

Testing with a fake

The payoff of one interface is that there is one thing to fake. The shared test module has a FakeContentSourceAdapter with settable results, call counters and gates that hold a call open until a test releases it. More than seventy test files use it. A sketch of the kind of test it makes cheap:

// Simplified sketch of a repository test. @Test fun `answers with the last good list when the source fails`() = runTest { val categories = listOf(MediaCategory(id = "1", name = "Action")) adapter.categoriesResult = Result.success(categories) repository.getCategories() clock.advanceBy(CATALOG_TTL) // the cached copy is now stale adapter.categoriesResult = Result.failure(ContentSourceException.Unavailable("fake")) assertEquals(categories, repository.getCategories().getOrNull()) } 

No network, no server, no emulator, and the clock is injected so time is a variable. The same fake drives view-model tests, so a screen can be checked against a source that is slow, failing, or has half the fields missing.

What I would take from it

Put the integration point in the first hour, make the models say what they do not know, spell failures as types, and give every optional call a default that behaves. The result is a TV app where adding a kind of source does not change how a screen browses or plays it.

Read more