Case study

ReadThat 3: The Media Feed

6 min read✦ AI generated with human guidance & review

An immersive shared vertical media pager that reuses the ranked feed’s Room snapshot and cursor, one native player per process, tiered preloading for first-frame playback, and platform-optimal pooled HTTP and HLS engines.

Part 3 of the ReadThat case study.

📲 Try it live: Download the ReadThat APK (8.3 MB, Android 8+). Open the file on your phone and allow “install unknown apps” if prompted.

Tap any image or video in the normal feed and ReadThat opens MediaFeed: a spinner-free, vertically snapping pager showing the exact tapped post, with the same media that precedes and follows it in that feed generation. Swipe down for the next post’s video, already playing. Full contract: docs/MEDIA_FEED.md.

📷 Video placeholder: feed tap → MediaFeed open → three swipes, no spinners.

Reusing the feed’s data, not refetching it

The hard requirement is continuity: MediaFeed must show the same ranked generation the user was just scrolling, not a fresh query that may have reranked. So the launch path reads the feed’s own cache:

  1. On tap, OfflineFirstRepository.mediaLaunchContext reads ordered feed_groups, joined optimistic state, and the feed’s next cursor in one Room transaction, filters that exact generation to media, records the anchor index, and returns an ephemeral NormalFeedMediaContext.
  2. The shared SharedMediaFeedController converts it to typed items, and SharedMediaFeedRepository persists snapshot-scoped MediaFeed membership before the Pager starts. The anchor keeps its absolute index, so swiping back reaches prior cached media.
  3. Only the opaque snapshotId rides the navigation route. After process death, that token reconnects to the persisted Room scope and the saved pager position.
  4. At the cached tail, the repository continues from the feed’s own opaque cursor: HttpMediaFeedRemoteSource namespaces it (ranked-feed-v1:) and pages /v1/feed while projecting out non-media groups. One timeline, guaranteed.
HTTP cursor page -> MediaFeedRemoteMediator -> Room transaction
├─ media_post_content
├─ media_feed_entries
├─ media_feed_remote_keys
└─ shared item_state
|
PagingSource<MediaFeedRow> -> VerticalPager

OfflineFirstRepository.mediaLaunchContext · SharedMediaFeedController, repository, and mediator

Votes reuse the shared item_state + vote_outbox: the same optimistic write shows in feed, MediaFeed, and detail simultaneously. There is also a separate typed endpoint (GET /v1/feeds/media) for entry points without a feed context; it shares the ranking and ACL policy, covered in part 6.

One native player per process. Exactly one.

The single most important media decision is shared across platforms: there is at most one native player in the process—ExoPlayer on Android, AVPlayer on iOS. The common :core:media-ui contract and shared feature UI own playback intent, progress, seeking, mute state, and preload windows; target actuals retain the best platform engine.

/**
* Process-scoped playback owner.
*
* There is exactly one ExoPlayer/decoder for the feed + detail surface. Feed
* cells and detail temporarily own PlayerViews, while PlayerView.switchTargetView
* moves the player atomically between them. The player is created lazily only
* when a video actually becomes active; preloading does not create a second one.
*/
object VideoPlaybackCoordinator {

VideoPlaybackCoordinator.kt#L130-L140

Android surfaces own lightweight PlayerViews, never players, and register with a priority: Detail > MediaFeed > inline Feed. PlayerView.switchTargetView attaches the destination view before detaching the source, so feed→detail keeps the decoder, position, buffer, and mute state. A TextureView (not SurfaceView) makes the shared-element geometry animation possible. The iOS actual applies the same ownership contract to one process-scoped AVPlayer and hands prefetched AVURLAssets directly to it.

Decoders are the scarcest resource on low-end devices; a pool of players is how you get codec-exhaustion crashes in the field. One player + preloaded sources gets the same UX for a fraction of the risk.

Prefetch: playing on frame 1

Media3’s DefaultPreloadManager warms upcoming items by distance from the focused index, with an explicit tier ladder:

internal enum class VideoPreloadTier { None, Source, Tracks, Loaded }
internal fun videoPreloadTier(distance: Int, playbackActive: Boolean): VideoPreloadTier = when {
!playbackActive && distance == 0 -> VideoPreloadTier.Loaded // about to play: load 3s
playbackActive && distance == 0 -> VideoPreloadTier.None // playing: player owns it
distance == 1 -> VideoPreloadTier.Loaded // next: samples loaded
distance == -1 -> VideoPreloadTier.Loaded // previous: samples loaded
distance.absoluteValue == 2 -> VideoPreloadTier.Tracks // tracks selected
distance.absoluteValue <= 4 -> VideoPreloadTier.Source // manifest prepared
else -> VideoPreloadTier.None
}

VideoPlaybackCoordinator.kt#L86-L95

When a swipe lands, the next item already has samples in memory: the first frame renders immediately. The budget is explicit: aggregate preload SampleQueue memory is capped at 12 MiB, wired straight into the load control:

private val loadControl = DefaultLoadControl.Builder()
.setBufferDurationsMs(5_000, MAX_FORWARD_BUFFER_MS, 1_000, 1_500)
.setBackBuffer(5_000, true)
// Bound aggregate SampleQueue memory used by nearby preloads.
.setPlayerTargetBufferBytes(PlayerId.PRELOAD.name, 12 * MIB)
.build()

VideoPlaybackCoordinator.kt#L385-L392

In the normal feed the same coordinator is fed by scroll position, and autoplay ownership is separate from warming: a video must reach half its maximum possible viewport exposure, then the candidate with the largest visible area owns the player; a departing sliver can’t block the already-warm next item. Preload requests are owner-scoped, so a disposed screen can’t clear another screen’s window.

Images ride the same philosophy through Coil: MediaFeed warms the current/adjacent gallery window plus upcoming posters, retains the disposable handles, and cancels as the window moves.

One HTTP client, pooled connections

On Android, every API response, Coil image, and Media3 segment flows through the same process-wide engine (UnifiedTransport): one QUIC/HTTP-3 HttpEngine on API 34+, one pooled OkHttp HTTP/2 client below. Media3 gets it via an adapter:

fun mediaDataSourceFactory(context: Context): DataSource.Factory =
if (Build.VERSION.SDK_INT >= 34)
HttpEngineDataSource.Factory(engine(context), executor())
else
OkHttpDataSource.Factory(okHttp())

UnifiedTransport.kt#L174-L192

The payoff: requests to a given origin reuse that origin’s TLS/session and pooled connection instead of creating a second Media3-owned client. Different API/CDN origins still require their own connections—sharing an engine does not erase origin boundaries—but pool ownership, security, cancellation, and telemetry stay consistent. iOS similarly keeps one long-lived URLSession for API/images/previews while native AVFoundation remains the HLS engine. The full transport story, HTTP/3 migration, and constrained-network policy is part 9.

Two-tier cache for media

Same L1/L2 pattern as structured data, different budgets:

  • L1: decoded bitmaps in Coil’s memory cache (12% of app memory on low-RAM devices, 20% otherwise); for video, the preload sample queues above.
  • L2: Coil’s disk cache (quota-aware, capped 64/128/256 MiB) and one bounded Media3 SimpleCache for segments only: dynamic HLS/DASH manifests are never cached. Cache keys are stable content identities, independent of expiring signed URLs, and shared across feed/MediaFeed/detail so a warmed segment serves all three surfaces.

SimpleCache scans its index at construction, so initialization runs on an IO dispatcher behind a shared future; UI leases queue briefly rather than doing filesystem work on the main thread. A cache-open failure degrades to the pooled network path, never a second player. Sizing logic is covered in part 5.


← Part 2: The SDUI feed · Next: Part 4: Post detail & comments →