Skip to content

3. Offline Downloads

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 alongside

Three kinds of work, and only one of them is interesting:

  1. Segments are copied byte-for-byte. They arrive AES-128 encrypted and they stay AES-128 encrypted — nothing decrypts them at download time.
  2. Playlists are rewritten, because every URL inside them points at a CDN that will not be reachable.
  3. The key is fetched once, with the signed headers, and saved.
┌────────────────────────────────────────────────────────────────────┐
│ 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.ts

Why 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!

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.

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 ✓ served

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.

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 locally

The 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.

  • lib/data/services/hls/hls_manifest_parser.dart — fetches and parses the manifests; HlsKeyPlan (L14-23) carries the absolute key URL and defines localFilename = 'key.bin'.
  • lib/data/services/hls/download_task_factory.dart:82-95 — the key task, the one enqueue that passes headers: keyHeaders. L147+ builds the same tasks for a resume, only for files actually missing.
  • lib/data/services/hls/download_orchestrator.dart:108,198 — where EncryptionHelper().playerHeader() is called for those headers.
  • lib/data/services/hls/local_playlist_writer.dart:38-42 — the key URI rewrite, reusing CastPlaylistRewriter.keyUriPattern so 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 — _initializeProxyServer and _toRelativeAssetPath, including the comment explaining the iOS container-UUID problem.
  • lib/domain/entities/cache_content_entity.dart — the Hive row, including localMasterRelativePath and cachedPlanJson.
  • android/app/src/main/kotlin/com/prothomalo/DownloadForegroundService.kt — keeps the process alive against OEM battery managers while a download runs.

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.