Quiblo wiki

Architecture

The player

Media3 behind an interface, what that interface buys, and the stutter that came from one retry policy.

# PlayerController

Feature code never touches ExoPlayer. It talks to PlayerController, and :core:media is the only module that knows ExoPlayer exists.

Two payoffs. First, the television and the phone drive the same engine with completely different controls — five keys versus gestures — without either knowing how the other works. Second, this is the seam where DRM slots in later: a format that needs a licence exchange is a change inside :core:media and nowhere else.

# What "play this" means

One entry point handles three quite different things, and getting its arguments right is what distinguishes a channel from a film from an episode:

KindWhat is passed
Live channelThe row id. Position is meaningless, so it is zero
FilmThe row id, and optionally an explicit start. Given none, the stored resume point is read
Series episodeThe series row id, plus the episode's stream URL, its title, and its season and episode numbers

The episode case is the one that catches people. A series row carries no playable stream — its streamUrl is not an episode — so an episode must be handed in explicitly. The season and episode numbers travel with it because the player records them into history and cannot derive them from a URL.

The distinction between a null start position and a zero one is deliberate: null means "wherever it was left", zero means the viewer chose to start again. Collapsing them makes "start from the beginning" impossible to express.

# Settings that reach the engine

Skip interval, buffer mode and maximum bitrate are stored in DataStore, emitted as flows, and read by the player from the same flow the settings screen writes to. A change applies without a restart, and there is exactly one source of truth.

Aspect ratio is handled by scaling the video surface rather than by asking the engine to re-letterbox: Fit, Fill, Zoom and Stretch are a computation over the video's aspect ratio and the container's.

# Why a film stalled every few seconds

VOD playback stuttered — a second or two of freeze, every few seconds, indefinitely, on streams that were perfectly healthy. The cause is a good illustration of two opposite problems being given one answer.

Live and VOD want different things from a failed load. A dead live stream must be given up on quickly: an acceptance criterion caps it at fifteen seconds, and the engine's own retry ladder stacked underneath our reconnection logic blew that budget. So the engine was told to attempt a load once and hand back the error.

That setting then applied to films as well, and a film is a different animal: one long read from one host, where a transient hiccup partway through is entirely normal. Failing after a single attempt handed the error to our retry, which restarts the whole media source — reopening the connection and rebuffering — for something the engine would have absorbed by re-requesting the same byte range.

And because reaching STATE_READY resets the attempt counter, the loop never escalated to an error and so never stopped. It just stalled, recovered, and stalled again, forever. A retry loop that succeeds every time is invisible to every error path you have.

The fix is one policy per kind: live keeps the fail-fast count, VOD gets the engine's default ladder back. The engine is rebuilt when the kind changes, because both the buffering policy and the load-error policy are fixed at construction.

# Three other things the engine is told

  • OkHttp, not HttpURLConnection. Media3's default holds no connection pool we control, so every segment paid a fresh TCP and TLS handshake. It is wrapped rather than swapped outright: a playlist imported from storage is a file:// URI, and an HTTP-only factory would simply fail to open it.
  • Decoder fallback is on. A failing hardware decoder is the single most common way playback dies on a cheap TV box — a corrupt header, a stream the vendor's codec mishandles, a green frame or audio over black. Fallback lets the engine try the next decoder that claims the format, in practice the platform software one, rather than surfacing an error the viewer can do nothing about.
  • Time beats size when the two disagree. The buffer setting is expressed in seconds, and a high-bitrate remux hits the byte ceiling long before the time target. The viewer picked a duration, so the duration wins.

Stream requests carry the same user agent the API client sends. Some panels gate on a recognised player agent, and answering as two different clients on one account invites precisely the attention we have already paid for once.

# What the player reports about itself

Two numbers, both there so smoothness can be measured rather than argued about: time to first frame, and a rebuffer count that only counts buffering after the first frame — because buffering before it is startup, and a measure that conflates the two cannot show an improvement in either.

They exist for the acceptance sweep. "It feels smoother" is not evidence, and the stutter above is exactly the kind of fault that hides in that gap: intermittent, recoverable, and invisible to every error path in the app.

# Announcing what a silent screen cannot

Buffering and playback failure are announced as live regions on both apps. This is the one place in Quiblo where a screen reader has genuinely less to work with than a viewer does: a stream that is buffering and a stream that has died look different — a spinner, or a message — but produce no focus change and no content change a reader would otherwise narrate.

The message text is shared between the two apps rather than written twice. There is one set of words for "unreachable", "timed out", "unsupported format", "DRM", and "gone", and a second copy would only mean a second thing to translate and a second thing to leave behind.

# Live is not a special case, except where it is

A live stream is prepared like anything else, but three behaviours differ, and in each case the control is absent rather than present and inert:

  • Seeking is meaningless, so skip controls do not appear, and on the television the seek keys are left unhandled rather than swallowed.
  • There is no duration, so no progress bar — the controls say "live" instead.
  • There is no resume point to store or read.

Conversely, zapping — moving up and down through the list you came from — applies only to live. The next film in a category is not "the next channel" in any sense a viewer means by pressing up.