Quiblo wiki

Code reference · Feature

:feature:*

The ViewModels, and the state they expose. Shared by both applications.

dev.quiblo.feature.*

These hold no Compose types, which is what lets the television reuse every one of them unchanged. Do not fork them. The moment there are two BrowseViewModels, a fix to the guide-request guards has to be made twice and the second will be forgotten — which is how the panel block comes back.

Each exposes one immutable state object rather than several independent streams, so a screen cannot render a half-updated combination.

classBrowseViewModel

Drives Live, Movies, Series and Favourites — one implementation, not four.

Parameterised by a BrowseFeed (a kind plus a favourites flag). The screens differ in what they show, not in how they behave.

It carries the prefetch guards, and they are the easiest thing in the project to break by accident:

  • onRowVisible fetches a guide entry — only for live channels. Series carry a providerStreamId too, so without the kind check, scrolling the Series tab fires a guide request per row that can only ever come back empty.
  • onPosterVisible fetches a score — and deliberately does not fetch a guide, because a poster shows no programme and a grid holds several times more items than a list.
  • Requests are remembered per session, so re-composition while scrolling does not re-ask.
uiStateOne BrowseUiState: items, categories, filter, guide, scores, artwork, history.
onRowVisible(channel)Called when a list row settles. Live only.
onPosterVisible(channel)Called when a poster settles. Films and series only.
selectCategory(title) / search(text)Filter and search, both resolved in SQL.
channelForHistory(entry)Resolves a history entry back to a playable row. Null when the provider has dropped the title.
scheduleFor(channel)One channel's listing across the window a timeline draws, straight from storage.
requestFullGuide(channel)Asks the provider for the whole listing. Live only, once per channel per session, and never from a scroll.

data classBrowseUiState

Everything a browse screen renders, as one object.

isLoading and hasSource are separate fields deliberately. They were conflated once, so every browse screen opened by telling the user to add a playlist — including when they had one and it was still loading. Advice that is wrong for the first second is worse than a spinner.

data classGuideTimeline / GuideBlock

One channel's listing laid out against elapsed time, by guideTimeline(). No Compose in it, on purpose.

Both apps draw a timeline and neither draws it the same way — a phone scrolls it under a finger, a television walks it with a D-pad — but where each programme sits, how wide it is and which one is on now are the same answers on both. So they are decided here, in a plain function, and each app turns the fractions into its own drawing.

The cases it exists to get right are the ones a provider actually sends: listings that overlap (the earlier one keeps its ground), listings with holes (a hole is a block, so a timeline missing an hour does not look complete), and programmes that began before the window (clipped, never dropped). isNow is carried per block rather than left to a marker line, because a viewer three metres from the screen is reading whichever block has focus.

guideTimeline(programmes, now, hoursBehind, hoursAhead)Lays a listing out across the window. An hour behind and twelve ahead by default.
guideWindow(now, …)The window itself, so the query and the drawing cannot disagree about it.
GuideBlock.nowFraction / isNowWhere the marker goes, and which block is playing. Null fraction when the window does not contain now.

classSearchViewModel

One search across every kind, and the genre filter beside it.

Deliberately one screen rather than a search box on each of Live, Movies and Series. Panels routinely list the same film as a film and as a one-episode series, so a search that answers for one kind is a search that appears to have found nothing.

Keystrokes are debounced and the latest question cancels the previous one — the repository's reads are one-shot precisely so a term already moved past is not still being answered.

It reuses BrowseViewModel's guards for poster scores rather than reinventing them: a re-composed row must not re-ask, and concurrent lookups are capped.

uiStateOne SearchUiState. isActive is what moves the field off the middle of the screen.
coveragePercentHow much of the catalogue is described. Shown, not hidden — a filter is only as complete as its cache.

classProfilesViewModel

The chooser: listing profiles, adding one, and starting a guest session.

Shared by both apps' choosers and both settings screens, so "who is watching" cannot mean two different things on one device.

classPlayerViewModel

Loads an item into the player and records where the viewer got to.

load is the single entry point for three different things, and its arguments are what distinguish them. A live channel needs only an id. A film needs an id, and a nullable start position — null means "wherever it was left", zero means the viewer chose to start again, and collapsing the two makes "start from the beginning" impossible to express. An episode additionally needs its stream URL and its season and episode numbers, because an episode is never a row and the player cannot derive them from a URL.

load(channelId, customUrl?, customTitle?, startPositionMillis?, seasonNumber?, episodeNumber?)Prepares playback. Guarded so a recomposition does not restart a stream already playing.
skipBy(direction)Moves by the configured interval, clamped so a rewind cannot run past the start.
controllerHandle()The controller, for attaching a video surface.

classMovieDetailViewModel / MovieDetailUiState

A film's artwork, plot, score, resume point and favourite state.

isEnriching exists because "not asked yet" and "asked, and there is nothing" look identical in the data and must not look identical on screen. Without it the screen asserted "no description" for the moment before the answer arrived, which reads as a wrong answer rather than a pending one.

canResume ignores positions under ten seconds — below that, offering Resume as a distinct choice is noise.

classSeriesDetailViewModel / SeriesDetailUiState

Seasons, episodes, and the episode a viewer was last on.

resumeEpisode is the most recently watched episode, not the furthest-through one. Resume keys are episode stream URLs, because that is what the player records against when handed a custom URL.

classSourcesViewModel / AddSourceState

Adding, refreshing and deleting sources.

addM3uSource and addXtreamSource return whether the input was accepted. A caller needs to know: the television form used to close on a rejected save, leaving no source, no message and nothing to distinguish that from never having pressed the button.

classSettingsViewModel / BackupUiState

Every setting, plus category editing and backup.

Reused whole by the television, so a setting means the same thing on both apps and can only be wrong in one place.