3. Offline Downloads
3. Offline Downloads
Section titled “3. Offline Downloads”Where we left off
Section titled “Where we left off”Doc 2 ended on a phone with a network connection, where the native player fetches the key itself and nobody has to think about it. Now take the network away.
Offline playback has a problem the online path never faces: the key lives on the internet, and the whole point is that there is no internet. So the key has to be fetched in advance, at download time, while the signed headers can still be produced — and then replayed from disk months later.
That single requirement shapes everything else here, including why the app runs a web server against itself.
What “downloading a film” actually means
Section titled “What “downloading a film” actually means”HLS is not one file. A download has to reproduce a small website:
UPSTREAM (the CDN) ON DISK (<Documents>/<slug>/) ───────────────────── ────────────────────────────── master.m3u8 ──► master.m3u8 (rewritten) ├── 720p/media.m3u8 ──► video.m3u8 (rewritten) │ ├── #EXT-X-KEY URI= ───┼─► key.bin ◄── THE KEY │ ├── segment_000.ts ──► segment_000.ts │ ├── segment_001.ts ──► segment_001.ts │ └── … hundreds more ──► … └── audio_bn/media.m3u8 ──► audio_bn/media.m3u8 └── segments ──► audio_bn/… thumbnail, poster, .vtt subs ──► stored alongsideThree kinds of work, and only one of them is interesting:
- Segments are copied byte-for-byte. They arrive AES-128 encrypted and they stay AES-128 encrypted — nothing decrypts them at download time.
- Playlists are rewritten, because every URL inside them points at a CDN that will not be reachable.
- The key is fetched once, with the signed headers, and saved.
The pipeline
Section titled “The pipeline” ┌────────────────────────────────────────────────────────────────────┐ │ 1. PARSE HlsManifestParser │ │ Fetch master.m3u8, pick the variant matching the user's │ │ quality preference, fetch that media playlist and each audio │ │ rendition. Extract: the segment list, and the #EXT-X-KEY URI │ │ (resolved to absolute) as an HlsKeyPlan. │ └───────────────────────────────┬────────────────────────────────────┘ ▼ ┌────────────────────────────────────────────────────────────────────┐ │ 2. ENQUEUE DownloadTaskFactory → background_downloader │ │ │ │ one task per segment ──────────────────► no headers │ │ one task per subtitle / image ─────────► no headers │ │ ONE task for the key ──────────────────► headers: keyHeaders │ │ = playerHeader() │ │ │ │ The key task is the ONLY download in the app that carries the │ │ signed headers. It is also the only one that would 403 without │ │ them. │ └───────────────────────────────┬────────────────────────────────────┘ ▼ ┌────────────────────────────────────────────────────────────────────┐ │ 3. REWRITE LocalPlaylistWriter │ │ │ │ segment lines: https://cdn/…/segment_000.ts → segment_000.ts│ │ the key line: URI="https://kms.chorki.net/…" → URI="key.bin"│ │ master.m3u8: synthesised fresh, pointing at video.m3u8 and │ │ each audio folder │ └───────────────────────────────┬────────────────────────────────────┘ ▼ ┌────────────────────────────────────────────────────────────────────┐ │ 4. RECORD Hive box 'downloads' → CacheContentEntity │ │ localMasterRelativePath, plus cachedPlanJson — the entire │ │ parsed plan, so a resume never has to re-parse the manifest │ │ (and never has to reach the network to know what is missing). │ └────────────────────────────────────────────────────────────────────┘After step 3, that folder is a self-contained HLS site with every URL
resolved locally. The #EXT-X-KEY tag is still there, still saying
METHOD=AES-128 — it just points at a file next to the segments instead
of at a server.
BEFORE AFTER #EXT-X-KEY:METHOD=AES-128,URI= #EXT-X-KEY:METHOD=AES-128,URI= "https://kms.chorki.net/v1/keys?…" "key.bin" #EXTINF:4.0, #EXTINF:4.0, https://cdn/…/segment_000.ts segment_000.tsWhy a web server, and not just a file path
Section titled “Why a web server, and not just a file path”Both players can open a local file. So why does the app start an HTTP
server on 127.0.0.1:8080 and play from a URL?
Because a media playlist is not a file, it is a set of relative
references, and the players resolve those differently and awkwardly
against file:// URLs — especially the key URI, which iOS handles with
different rules than segments. Serving over HTTP makes the local copy look
exactly like the remote original: same relative resolution, same range
requests, same MIME negotiation. The player cannot tell the difference,
which is precisely the goal.
AVPlayer / ExoPlayer │ │ GET http://127.0.0.1:8080/master.m3u8 ▼ ┌──────────────────────┐ │ LocalProxyServer │ reads from <Documents>/<slug>/ │ (dart:io HttpServer)│ supports Range requests └──────────────────────┘ │ ├─► master.m3u8 → application/vnd.apple.mpegurl ├─► video.m3u8 → application/vnd.apple.mpegurl ├─► segment_*.ts → video/mp2t └─► key.bin → application/octet-stream ◄── matters!The MIME type of the key matters
Section titled “The MIME type of the key matters”This is the kind of detail that costs an afternoon:
iOS AVPlayer refuses to use a key returned with a media MIME type.
The server’s content-type table matches on file extension. A key file has
no meaningful extension, so the fallback — application/octet-stream —
is what it gets, and that fallback is deliberate rather than accidental.
Give
the key video/mp2t and playback fails in a way that looks like corrupt
video rather than a bad header.
The basename fallback
Section titled “The basename fallback”Audio renditions live in a subfolder, and their playlist also carries a
key URI rewritten to the bare name key.bin. The player resolves that
relative to the playlist’s own directory, so it asks for
/audio_bn/key.bin — which does not exist, because there is one key at
the root.
Rather than write the key file into every folder, the server retries with just the basename before returning a 404:
GET /audio_bn/key.bin │ ├─► <base>/audio_bn/key.bin does not exist └─► <base>/key.bin ✓ servedThe path is re-derived on every launch
Section titled “The path is re-derived on every launch”The Hive row stores an absolute path that was correct at download time.
On iOS that path is wrong after any reinstall, because the app container
UUID changes. So the stored path is chopped back to a relative
<slug>/master.m3u8 and re-resolved against the current Documents
directory before the server binds. A download survives a reinstall
because the app never trusts the absolute path it wrote down.
Playing it back
Section titled “Playing it back” Download screen ──► CastAwareNavigation.play(…, isOffline: true) │ ▼ VideoPlayerScreen.initState sees isOffline │ ▼ _initializeProxyServer(stored path) _toRelativeAssetPath() → "<slug>/master.m3u8" resolvePlaybackUrl() → binds the server to <Documents>/<slug> → "http://127.0.0.1:8080/master.m3u8" │ ▼ buildPlayerMedia(url: <that>, headers: const {}) └─ internet_available is false, so no signed headers are attached │ ▼ native player → fetches master, video.m3u8, sees #EXT-X-KEY → GET /key.bin from the loopback server → 16 bytes, decrypts every segment locallyThe key request still happens. It is still a plain HTTP GET for a
#EXT-X-KEY URI. It just terminates inside the app’s own process instead
of at kms.chorki.net — which is exactly why nothing in the player needed
to change to support offline playback.
Where this lives in the code
Section titled “Where this lives in the code”lib/data/services/hls/hls_manifest_parser.dart— fetches and parses the manifests;HlsKeyPlan(L14-23) carries the absolute key URL and defineslocalFilename = 'key.bin'.lib/data/services/hls/download_task_factory.dart:82-95— the key task, the one enqueue that passesheaders: keyHeaders. L147+ builds the same tasks for a resume, only for files actually missing.lib/data/services/hls/download_orchestrator.dart:108,198— whereEncryptionHelper().playerHeader()is called for those headers.lib/data/services/hls/local_playlist_writer.dart:38-42— the key URI rewrite, reusingCastPlaylistRewriter.keyUriPatternso the cast proxy and the download writer share one definition of the pattern.lib/domain/services/local_server.dart— the loopback server: the MIME table and octet-stream fallback (L21-29), the basename fallback (L54-63), Range support (L79-115).lib/data/services/local_playback_server_controller_impl.dart— binding, and re-binding when a different title is played.lib/presentation/pages/content_pages/video_player_screen.dart:431-463—_initializeProxyServerand_toRelativeAssetPath, including the comment explaining the iOS container-UUID problem.lib/domain/entities/cache_content_entity.dart— the Hive row, includinglocalMasterRelativePathandcachedPlanJson.android/app/src/main/kotlin/com/prothomalo/DownloadForegroundService.kt— keeps the process alive against OEM battery managers while a download runs.
The takeaway
Section titled “The takeaway”Offline playback works by making the local copy indistinguishable from the
remote original: same playlists, same relative URLs, same HTTP semantics,
same #EXT-X-KEY tag — only the host changed, from a CDN to
127.0.0.1:8080. The one genuinely privileged moment is a single download
task, the key, which is the only file in the whole pipeline that needs the
signed headers. And once that 16-byte file is on disk, it stays there in
the clear, next to the segments it unlocks. Doc 5 comes back to what that
means.