ReadThat 3: The Media Feed
An immersive vertical media pager that reuses the ranked feed’s repository and cursor, one ExoPlayer for the whole process, tiered preloading so video plays on the first visible frame, and one shared HTTP engine under API, images, and segments.
Part 3 of the ReadThat case study.
📲 Try it live: Download the ReadThat APK (8.8 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:
- On tap,
FeedRepositoryreads orderedfeed_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 ephemeralNormalFeedMediaContext. :appconverts it to typed items and persists a snapshot-scoped MediaFeed membership before thePagerstarts. The anchor keeps its absolute index, so swiping back reaches prior cached media.- Only the opaque
snapshotIdrides the navigation route. After process death, that token reconnects to the persisted Room scope and the saved pager position. - At the cached tail, the repository continues from the feed’s own opaque cursor:
HttpMediaFeedRemoteSourcenamespaces it (ranked-feed-v1:) and pages/v1/feedwhile 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> -> VerticalPagerMediaFeedRepository.kt · MediaFeedRemoteMediator.kt
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 player instance. Exactly one.
The single most important media decision: there is at most one ExoPlayer in the process.
/** * 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#L112-L121
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. The video never restarts across navigation. A TextureView (not SurfaceView) makes the shared-element geometry animation possible.
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#L84-L93
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#L360-L367
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, one connection
Every media byte, API JSON, Coil images, Media3 segments, 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())The payoff: segments reuse the TLS session, congestion state, and connection the API already established. No second DNS lookup, no second handshake before the first frame. The full transport story, HTTP/3 migration, metered awareness, 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
SimpleCachefor 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 →