ReadThat 3: The Media Feed
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:
- On tap,
OfflineFirstRepository.mediaLaunchContextreads 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. - The shared
SharedMediaFeedControllerconverts it to typed items, andSharedMediaFeedRepositorypersists 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> -> VerticalPagerOfflineFirstRepository.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())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
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 →