Mobile Design Rounds
Build guideAndroid + iOS19 min read

Build it: a video streaming app on Android and iOS

Last reviewed . Drafted with AI assistance. Checked against official documentation where noted (how this is checked). Part of: Design a video streaming app (Android and iOS): adaptive streaming, preload and offline · The framework.

This is the implementation material behind the video streaming app interview case. It covers:

  • the schema (the layout of the database tables) and the REST API (the calls to the server)
  • HLS and DASH (two video streaming formats), and what the packaging team must provide
  • the player service, the caches, preload and downloads
  • DRM (the locks on licensed video), data limits, resume, quality metrics, platform limits and the iOS version

None of it fits in the 45 minutes. I draw it only if the interviewer asks.

The code uses Room 2.x (androidx.room), the Android library for the database on the phone. The official Room guide now shows Room 3 (androidx.room3, KSP only). I checked the code for Media3 (the Android media library) against Media3 1.9.0. The newest stable release is 1.11.1. Versions 1.10 and 1.11 changed MediaSessionService and MediaSession threading. Build the code again (recompile) before you copy it.

1. Local schema (Room)

@Entity(tableName = "videos")
data class VideoEntity(
    @PrimaryKey val id: String,
    val title: String,
    val thumbnailUrl: String,
    val durationMs: Long,
    val manifestUrl: String?,          // fetched lazily
)

@Entity(tableName = "playback_progress")
data class ProgressEntity(
    @PrimaryKey val videoId: String,
    val positionMs: Long,
    val updatedAt: Long,               // server-stamped on every successful PUT
    val dirty: Boolean,                // changed locally, not yet uploaded
)

The app tracks downloads in a separate place (state, bytes, license expiry). The repository (the code that gets data) turns the database rows into models that never change. It uses Flow (a stream of updates) to watch data.

2. REST

GET /v1/feed?cursor=<opaque>&limit=10&fields=id,title,channel.name,thumb,durationMs
GET /v1/videos/{id}/playback   → { manifestUrl, drm: {type, licenseUrl}?, resumeMs }
PUT /v1/videos/{id}/progress   → { positionMs, deviceId }   // server stamps updatedAt

Three details matter:

  • Field selection on the feed. A feed row needs a thumbnail, title, channel and duration. It does not need descriptions, caption lists or manifest URLs. Let the app name the fields (fields=, or a GraphQL selection), so a list of ten items stays small. The feed uses a cursor (a saved position in the list), and paging works like the news feed case.
  • Manifest on demand. Call /playback when a video is about to play or preload, not for every row. Signed URLs (links with a time limit) expire, so a late call avoids old links.
  • Progress as a PUT of an absolute position. It is idempotent (doing it twice has the same result as doing it once), so a retry (sending the request again after a failure) is safe. The server adds updatedAt. I do not send a time from the phone, because phone clocks are not exact. Section 10 has the rule for conflicts.

3. HLS, DASH and what packaging must give the client

Both formats cut a video into short segments at several bitrates (a bitrate is the number of bits per second). A manifest (a list of the segments and quality levels) describes them. HLS (HTTP Live Streaming) uses an .m3u8 playlist. DASH is the other common format, and it uses an .mpd file.

Apple documents that HLS offers alternate streams at different bit rates. The player switches when bandwidth changes. (Bandwidth is how much data the link can carry.) On Android, Media3 plays both formats after you add the format module. Use media3-exoplayer-hls, and media3-exoplayer-dash for DASH.

After that, MediaItem.fromUri(uri) is enough when the URI ends in .m3u8 or .mpd. A multivariant HLS playlist lists several variants (the same video at different qualities). The player switches between them using bandwidth and device ability. If an HLS URI does not end in .m3u8, the HLS page says to pass MimeTypes.APPLICATION_M3U8 as the MIME type (the label for the file type).

On iOS, AVPlayer (the iOS player) plays HLS and progressive files (one plain video file). Apple documents no DASH support. So a catalog for both platforms usually ships HLS and adds DASH only if it must.

Startup time versus quality. A low first bitrate gives a fast but blurry start. A high one risks a stall (the video stops to wait for data). Do not write your own selection algorithm. Change its inputs instead: a lowest rung (quality level) that uses few bits, short segments, and caps (maximum quality) for each network type (section 8).

Here is what I ask the packaging team for (the team that prepares the video files). The reference is Apple's HLS authoring specification. The numbers below are from that specification, read on 2026-10-14:

AskWhat the specification saysWhy the client team wants it
Segment lengthSegments should be nominally 6 secondsShorter segments adapt faster but cost more requests. The number is a packaging decision, so I ask for startup data before I ask for 2 to 4 seconds
Default variantDefault variant should be 2,000 kbit/s average for Wi-Fi delivery and 730 kbit/s for cellular (iOS rules)A default rung that starts fast and costs about 330 MB an hour on cellular
Cellular floorA playlist delivered over cellular must have a variant at or below 192 kbit/s peak (iOS rules)A rung the player can fall back to on a very weak link
ScrubbingI-frame playlists must be provided; one frame per second is the suggested densitySeek previews and fast scan need them. Without them the scrub bar shows no pictures
An H.264 rungMy ask, not a rule in the specificationDevices without a newer codec still need one rung they can decode
OfflineOnly on-demand streams can be saved; a live stream throws an exception (persist sample)Live titles never appear in the downloads list

4. One player, owned outside the UI

On Android, put the ExoPlayer (the Media3 player) and a MediaSession (the object that shows controls to the system) inside a MediaSessionService. A service is a part of the app that runs without a screen. Media3 documents this as the way to keep playing when the app is not on the screen. The screen controls the player through a MediaController.

The AndroidManifest.xml file needs two permissions: FOREGROUND_SERVICE and FOREGROUND_SERVICE_MEDIA_PLAYBACK. The service declares foregroundServiceType="mediaPlayback". It also declares the androidx.media3.session.MediaSessionService intent filter (background playback guide). A foreground service is a service that the user can see through a notification. If any part is missing, the service fails when it tries to start in the foreground.

Android 17 adds stricter rules. The Android 17 behavior changes say audio playback and volume APIs fail with no error when the app is not in a valid lifecycle. Audio focus (the system rule for which app plays sound) requests return AUDIOFOCUS_REQUEST_FAILED.

For apps that target API 37, the foreground service must also have while-in-use capabilities. So I start playback from a user action, and I test on an Android 17 device. I only read the documents for this.

// Sketch: player owned by a service, backed by a size-capped cache
// Pattern from the Media3 background playback guide (Apache 2.0), with a cache factory added
class PlaybackService : MediaSessionService() {
    private var session: MediaSession? = null

    override fun onCreate() {
        super.onCreate()
        val cacheFactory = CacheDataSource.Factory()
            .setCache(VideoCache.get(this))                       // app-defined singleton SimpleCache
            .setUpstreamDataSourceFactory(DefaultHttpDataSource.Factory())
        val player = ExoPlayer.Builder(this)
            .setMediaSourceFactory(DefaultMediaSourceFactory(this).setDataSourceFactory(cacheFactory))
            .build()
        player.setAudioAttributes(
            AudioAttributes.Builder()
                .setUsage(C.USAGE_MEDIA)
                .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
                .build(),
            /* handleAudioFocus= */ true,
        )
        session = MediaSession.Builder(this, player).build()
    }

    override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = session

    override fun onDestroy() {
        session?.run { player.release(); release() }
        session = null
        super.onDestroy()
    }
}

By default the service keeps running when playback is on and the user swipes the app away. The documents show how to override onTaskRemoved with pauseAllPlayersAndStopSelf() if you want it to stop. The sketch above does not do this.

Points to say out loud:

  • Rotation. A new Activity (one screen) connects a MediaController again and attaches its surface (the area where video is drawn) again. The player and its buffer (video data loaded ahead) are not touched.
  • Picture-in-picture. Picture-in-picture (PiP, a small floating video window) needs API 26 on Android. Declare supportsPictureInPicture on the activity. The PiP guide also asks for android:configChanges, so the activity does not restart when PiP starts.
  • Automatic entry. On API 31 and later, call setAutoEnterEnabled(true) in PictureInPictureParams. Then a swipe to the home screen starts PiP, with no code in onUserLeaveHint. An active MediaSession gives the play and pause controls.
  • Audio focus. setAudioAttributes(attributes, true) lets ExoPlayer handle focus. So the app writes no focus callbacks. Media3 says to set attributes that describe your content.

5. Disk cache for segments (Android)

Media3 saves loaded bytes in a cache (a store of saved copies). It uses a SimpleCache plus CacheDataSource.Factory. The documents show LeastRecentlyUsedCacheEvictor(maxBytes) for caching while the app runs. At the size limit, the cache evicts (removes) the oldest segments. Use only one StandaloneDatabaseProvider in the app.

Choose maxBytes from product needs and device storage. Keep downloads in a separate cache with NoOpCacheEvictor. The downloading documents say a download cache should not evict media.

6. Preloading the next video

To preload means to load part of a video before the user needs it. Media3's DefaultPreloadManager preloads videos by their distance from the playing video. Your app decides how much of each.

In the Media3 sample, the next and previous videos get 3 seconds loaded. The video two away only gets its tracks selected (audio and video streams chosen). Videos three and four away only have their source prepared. Use these numbers as a first guess.

// Sketch, adapted from the Media3 preloading docs sample (Apache 2.0). The app must keep currentPlayingIndex current.
class FeedPreloadControl(var currentPlayingIndex: Int = 0) :
    TargetPreloadStatusControl<Int, DefaultPreloadManager.PreloadStatus> {

    override fun getTargetPreloadStatus(index: Int): DefaultPreloadManager.PreloadStatus =
        when (abs(index - currentPlayingIndex)) {
            1 -> DefaultPreloadManager.PreloadStatus.specifiedRangeLoaded(3000L)
            2 -> DefaultPreloadManager.PreloadStatus.PRELOAD_STATUS_TRACKS_SELECTED
            3, 4 -> DefaultPreloadManager.PreloadStatus.PRELOAD_STATUS_SOURCE_PREPARED
            else -> DefaultPreloadManager.PreloadStatus.PRELOAD_STATUS_NOT_PRELOADED
        }
}

Build the player with buildExoPlayer() on the same DefaultPreloadManager.Builder. The service sketch above uses a plain builder to keep the code short. DefaultLoadControl.Builder().setPlayerTargetBufferBytes("preload", ...) sets a maximum for total preload memory. On cellular, or in data saver mode (a phone setting that reduces data use), return a lighter status from this control. You can also return not preloaded.

Two more rules come from my own reasoning, not from a documented page:

  • Preload and playback should share one estimate of bandwidth. Otherwise a preload can take the data that the video on screen needs.
  • Each preloaded video holds memory. Keep a window of videos around the current one, and remove videos that leave the window.

On iOS there is no preload manager. Prepare the next video early. Either load the AVURLAsset keys, or put the item in an AVQueuePlayer after the current one. Change preferredForwardBufferDuration if you need to.

I did not confirm in Apple's documents that an AVPlayerItem that is not attached to a player starts buffering. Check that before you rely on it. I found no documented public API for a saved segment cache in AVPlayer. Plan to use the system's HTTP cache and your CDN (content delivery network, the servers near users).

7. Offline downloads and DRM

Android. Media3's DownloadManager runs downloads through a DownloadService. It saves files in a Cache and follows Requirements such as network type. DownloadHelper chooses tracks for adaptive streams (streams that change quality), so the user can pick a quality before the download starts.

Play downloaded video through a read-only CacheDataSource.Factory. Build the item with the download request's toMediaItem, so only the downloaded tracks play. Do not read the download files directly.

Downloads must continue after the app process ends. The Android architecture guide would use WorkManager (the Android tool for work that must finish later) for this. Media3 has its own DownloadService for it, and I use that.

The download service is a dataSync foreground service. On Android 15 and later, the foreground service timeout page gives those services 6 hours in each 24-hour period. After that, the system calls onTimeout, and the service has a few seconds to stop.

A big offline library can therefore pause in the middle, so downloads must be able to resume. My reading of the documents is that Media3 restarts them through its scheduler (WorkManagerScheduler or PlatformScheduler) when the time budget renews. I did not test this. Start downloads from a user action while the app is visible.

iOS. AVAssetDownloadURLSession needs a background configuration. Then downloads continue while the app is suspended (paused by the system). If the user force-quits the app (swipes it away), the system cancels active downloads. It does not start the app again.

Save the location that the delegate (the object that gets the download events) gives you. Then you can rebuild the asset at the next launch. I would save a path relative to the home directory, not a full URL. The container path can change. (I did not check this against current guidance.)

Create tasks with makeAssetDownloadTask(downloadConfiguration:). Apple marks the older asset and destination creators as deprecated (old, and not for new code). Apple also says you can start a non-discretionary download (one that the system does not delay) only while the app is in the foreground. Use those sessions only for downloads that a person starts. This matches the Android advice above (AVAssetDownloadURLSession).

DRM in short. DRM (digital rights management) puts locks on licensed video. Widevine on Android and FairPlay on iOS keep the decryption keys (the secrets that unlock the video) in the platform. The license server decides who can play and for how long.

  • Media3 supports Widevine with the two Common Encryption types (schemes): cenc from API 19 and cbcs from API 25, with DASH and with HLS in fragmented MP4. FairPlay is the iOS equivalent. It works through AVContentKeySession.
  • For offline play, the app stores a persistent license (a license saved on the device). On Android the key set ID goes on the download request and on the MediaItem.DrmConfiguration. On iOS, AVContentKeySession supports persistable keys (keys that can be saved). It also has APIs to get an expiration date and to invalidate keys (make them stop working).
  • Only one offline key set works for each playback (a known Media3 issue). Content with several keys works offline only if the server returns all keys in one response.
  • Licenses expire. Store the expiry, show it in the downloads list, and renew the license while online. Show a clear message for "license expired, no network", not a generic player error.

8. Buffering policy, network switching and data limits

  • Bandwidth caps. On Android, TrackSelectionParameters accepts limits. It applies them to every video in a playlist. setMaxVideoSizeSd() is the documented example. By default ExoPlayer limits video resolution (picture size) to the display size.
  • iOS caps. AVPlayerItem has preferredPeakBitRate. It also has a separate preferredPeakBitRateForExpensiveNetworks, and matching settings for resolution. You can cap expensive networks (cellular, and Wi-Fi hotspots) without your own network code. That property reacts only to expensive paths.
  • Data saver signals. Android's Data Saver depends on metered links (links that cost the user data or money): check isActiveNetworkMetered() first, then the three restrict-background states (Data Saver). On iOS, NWPath.isConstrained is documented as the signal for Low Data Mode. Send whichever signal you use into the dataSaver flag (an on-off setting) below.
  • Data saver policy. Lower the cap, stop preloading, and stop feed autoplay.
  • Buffer size. A bigger buffer (video data loaded ahead) protects against stalls but wastes data if the user leaves. ExoPlayer has a LoadControl for the buffering policy.
  • iOS buffer. preferredForwardBufferDuration sets how far ahead the player loads. automaticallyWaitsToMinimizeStalling is on by default. It delays playback when a stall looks likely.
  • Network switches. Let the player retry and change bitrate by itself. Show a spinner only after a short delay. Never create the player again when the connection changes.
// Sketch: one long-lived player; per-item limits for data saver
@MainActor final class PlaybackController {
    let player = AVPlayer()

    func configureSession() {
        // Playback category; background audio also needs the Audio, AirPlay, and Picture in Picture background mode.
        try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
    }

    func play(_ url: URL, dataSaver: Bool) {
        let item = AVPlayerItem(url: url)
        if dataSaver {
            item.preferredPeakBitRateForExpensiveNetworks = 1_500_000   // example value
            item.preferredForwardBufferDuration = 5                     // seconds, example value
        }
        player.replaceCurrentItem(with: item)
        try? AVAudioSession.sharedInstance().setActive(true)
        player.play()
    }

    func startupSeconds() -> Double? {
        // Apple: startupTime is negative when unknown
        player.currentItem?.accessLog()?.events.last.flatMap { $0.startupTime >= 0 ? $0.startupTime : nil }
    }
}

Apple suggests this: activate the audio session only when playback begins. Handle AVAudioSession.interruptionNotification to pause and resume. It works like the audio focus callbacks on Android (not verified here). For PiP, AVPlayerViewController has allowsPictureInPicturePlayback and canStartPictureInPictureAutomaticallyFromInline.

9. Autoplay in the feed

Autoplay (a video starts by itself) only the most visible video. Compute the visible part of each row. Pick the row with the largest part above a threshold (for example 60 percent, an example value), and move one player to it. Start muted, and pause when the row leaves the screen or the app goes to the background.

The costs are real:

  • Each active decoder (the part that turns video data into pictures) uses hardware and battery, so run one at a time.
  • Autoplay uses data, so allow it only on Wi-Fi or when a setting allows it.
  • Reuse one player and change its media item. Use the preload control to hide the delay of the change.
  • Show the thumbnail until the first frame appears, or the row shows black for a moment.

Thumbnails need the same rule as in image loading: cancel the request when the row scrolls away.

10. Resume and cross-device sync

Save the position on pause, on leaving the screen, and about every 30 seconds while playing (an example interval). Save on the phone first and mark the row dirty (changed, not yet uploaded). Use an injected app-scoped CoroutineScope.

Upload with work that survives when the system ends the app process. On Android that is a WorkManager job. On iOS it is a saved queue that the app empties when it comes to the foreground and when the network returns. This is the same pattern as the outbox (a saved list of changes waiting to upload) in the chat app, section 4.1.

When the video opens, the server returns resumeMs. If a row on the phone is still dirty, the position on the phone wins, because it is the newest action of the user on this device. Otherwise I use the server's resumeMs.

Across devices the server decides the order. It adds updatedAt to every successful PUT, and the newest stamp wins. I do not compare device clocks (the offline-first guide explains why). A position within the last few seconds counts as finished.

11. Analytics and QoE

QoE means quality of experience: how good the viewing is for the user. These are the numbers I track:

MetricMeaning
Time to first frameFrom play intent to first rendered frame. The main startup metric
Rebuffer ratioStall time divided by watch time; also count stalls per hour
Average and switched bitrateHow often and how far the player changed quality
Playback failure rateErrors per session, split by cause (network, DRM, decode)

On iOS, AVPlayerItemAccessLogEvent has these values: startupTime, numberOfStalls, observedBitrate, indicatedBitrate and switchBitrate. On Android, use player listeners and analytics callbacks to get the same data.

Queue the events and send them in batches (groups). The analytics SDK case, section 4.1, designs exactly that. Split the data by network type, device class and CDN, because averages hide problem cohorts (groups of users with the same trait).

12. Platform limits that change the design

LimitAndroidiOSSo the design changes by
Long background downloadsA dataSync service gets 6 hours in 24 on Android 15 and later, then onTimeoutA background download session continues while suspended; a force-quit cancels active downloadsDownloads must pause and resume, and the list must show "paused" and "interrupted" as normal states
Starting a downloadStart from a user action while the app is visibleA non-discretionary download can start only while the app is in the foregroundA download starts from a tap, never from a background job
Background audioMediaSessionService with the playback permissions; Android 17 needs a valid lifecycle for audioPlayback audio session category plus the audio background modeThe player lives in a service on Android and an app-level object on iOS
Data saverMetered link, then three restrict-background statesExpensive path (cellular, hotspots) and constrained path (Low Data Mode) are separate signalsOne app-level flag, fed by both signals, controls caps, preload and autoplay
Offline streamsDownloads work through Media3 for adaptive streamsOnly on-demand streams can be savedLive titles hide the download button
Picture-in-pictureAPI 26, supportsPictureInPicture, configChanges; auto-enter on API 31AVPlayerViewController PiP properties, the background mode and the playback categoryThe watch screen tests PiP entry from home on both platforms

13. How to build a video app on iOS (Swift)

ConcernAndroidiOS
PlayerMedia3 ExoPlayerAVPlayer with AVPlayerItem
Player UIPlayerView or custom ComposeAVPlayerViewController or AVPlayerLayer
FormatsHLS and DASHHLS and progressive files (no DASH support documented by Apple)
Background and lifecycleMediaSessionService plus MediaControllerAVAudioSession .playback plus the Audio, AirPlay, and Picture in Picture background mode, Now Playing info and remote commands (MPNowPlayingInfoCenter, MPRemoteCommandCenter, not verified here); keep the player in an app-level object
Audio focussetAudioAttributes(..., true)Audio session category, activate on play
PiPsupportsPictureInPicture, setAutoEnterEnabledAVPlayerViewController PiP properties, plus the background mode and playback category
PreloadDefaultPreloadManagerLoad the next asset's keys or queue it in an AVQueuePlayer; you own the policy
Segment cacheSimpleCache with LRU evictorNo documented public segment cache API found
DownloadsDownloadManager, DownloadServiceAVAssetDownloadURLSession (background), makeAssetDownloadTask(downloadConfiguration:)
DRMWidevine via MediaItem.DrmConfigurationFairPlay via AVContentKeySession
Bitrate capsTrackSelectionParameterspreferredPeakBitRate, ...ForExpensiveNetworks
QoE dataPlayer listeners and analytics callbacksAVPlayerItemAccessLog events

The Swift sketch is in section 8. The main difference: iOS gives you more ready-made tools for downloads and bitrate caps, and fewer for caching and preloading.

14. More practice prompts

Practice prompts, not a claim about what any interviewer asks.

  1. How would you add live streaming? (Live-edge latency, which is the delay behind the live point. Shorter segments. No long preload.)
  2. How do you cut startup time? (Preload, low first rung, short segments, fewer round trips before the manifest.)
  3. A viewer reports the wrong subtitle language on every second video. Where do you look? (Track selection with preferred languages, and whether the choice is saved per video or per user.)
  4. The user scrubs (drags the position bar) a long way. What happens to preloading and to time to first frame? (Cancel the preload for the old position and tie the policy to the current item. Seek preview thumbnails are a separate asset that I fetch only when needed. This is my reasoning, not from the docs.)
  5. A signed manifest URL expires mid-session, or one CDN degrades. What do you do? (Fetch /playback again and re-prepare at the current position. To switch to another server when one fails, the backend returns more than one host and the app switches after repeated failures. Design opinion, not documented.)
  6. How would the same decisions change for a feed of short vertical videos? (See news feed for the paging side. Preload depth gets smaller.)
  7. Picture-in-picture must start from the watch screen and from the home gesture. Which entry points do you test?
  8. The product wants a "next episode downloads itself" feature. Which limits from section 12 get in the way? (A product idea, not a design source.)

Further reading (official docs)

Verification notes (last reviewed 13 Oct 2026)

this guide is the implementation part of the video case. It moved to its own page on 2026-10-14, so the interview page stays inside 45 minutes. The Kotlin and Swift snippets and the REST block are carried over byte for byte from the audited page. I did not change them or recompile them in this pass. The section numbers, this intro and the cross-references changed. Audit fixes from 2026-10-13 stay in the prose: the dataSync 6-hour limit and onTimeout, the playback permissions and manifest declaration, onTaskRemoved, the HLS module and MIME type hint, the Android 17 background audio rules, the Apple download session rules, makeAssetDownloadTask(downloadConfiguration:), startupTime negative when unknown, and preferredPeakBitRateForExpensiveNetworks (iOS 15 and later, expensive networks only). I re-read these pages on 2026-10-14. The Apple HLS authoring specification: 6 second segments, a 2,000 kbit/s default for Wi-Fi, a 730 kbit/s default for cellular and the 192 kbit/s peak variant for cellular are in its iOS section; I-frame playlists must be provided, and one frame per second is suggested. The Apple persist sample: on-demand only. The foreground service timeout page: 6 hours in 24 for dataSync and mediaProcessing, tracked per type; mediaPlayback is not listed. The Media3 downloading page: a download cache that does not evict, a scheduler that restarts downloads when requirements are met, a read-only playback cache, and DownloadHelper track selection. The Media3 background playback page. The Media3 release notes (1.11.1 on 2026-09-10). The Data Saver page (its sample checks the metered flag first). I also re-read these. The Media3 downloading page: DownloadService declared as dataSync. The Android 17 behavior changes page: audio fails with no error outside a valid lifecycle, AUDIOFOCUS_REQUEST_FAILED, and a while-in-use capability for apps that target API 37. The saved Apple JSON for the download session: a force-quit cancels active downloads, and non-discretionary tasks start only in the foreground. The 3 second preload sample values rest on the 2026-10-13 audit, not on a page fetched today. NWPath.isConstrained is documented as the Low Data Mode signal; I read its abstract only. These items are new in this pass and are not from a fetched page: the H.264 rung ask, the shared bandwidth estimate and the window rule for preload, and the table column "So the design changes by". They are my reasoning. These items are still unverified: Now Playing and remote command behaviour, AVAudioSession.interruptionNotification, saving a relative download path, whether an unattached AVPlayerItem buffers, whether a 3 second preload against 6 second segments fetches a whole segment, Media3 restarting downloads through its scheduler after the timeout, the default values of DefaultLoadControl, C.AUDIO_CONTENT_TYPE_MOVIE and C.USAGE_MEDIA (not on a fetched page), the cellular streaming rule in the App Store Review Guidelines (I did not read it and name no guideline number), and the seek, captions and CDN failover answers (my reasoning). Example values, not rules: 60 percent, 30 seconds, 1.5 Mbps, 5 seconds. I compile-checked the Kotlin snippets offline on 2026-10-13 against Kotlin 2.3.21 (AGP 9.1.0, compileSdk 36), Media3 1.9.0 (exoplayer, session, datasource) and Room 2.8.4, with stubs only for app-defined placeholders. Media3 1.10 and 1.11 changed MediaSessionService, so recompile before you rely on the service sketch. Android Lint would want an opt-in annotation for the unstable Media3 APIs. I type-checked the Swift sketch on 2026-10-13 against the iOS 27 simulator SDK (Swift 6.4, language modes 5 and 6). The snippets are sketches; No iOS engineer has reviewed the iOS sections yet. The service sketch follows the pattern in the Media3 background playback guide (Apache 2.0).

Questions or corrections?

Add them to the discussion thread for this case on GitHub. To post, you need a free GitHub account. What you write there is public and follows GitHub's terms. Good answers are added to this page.

Keep reading

Get the next case study when it is published.

You get an email when a new case study is published, and nothing else.

You can unsubscribe at any time. Privacy.