2. Starting a Download
2. Starting a Download
Section titled “2. Starting a Download”Where we left off
Section titled “Where we left off”Doc 1 established that a title is a few thousand files and that the app
hands each one to background_downloader as its own task. This doc follows
a single tap on Download until those tasks are sitting in the native
queue.
Step 1: the gates in the UI
Section titled “Step 1: the gates in the UI”There are three places a download can start, and they apply the same checks in the same order:
| Entry point | File | Event it sends |
|---|---|---|
| Download button on a film’s page | content_page_button_widget.dart (_onTapDownload) |
DownloadContentEvent |
| Download icon on an episode row | single_content_screen.dart |
DownloadEpisode |
| Status icon on a row in Downloads | download_screen.dart (_onRowDownloadTap) |
DownloadContentEvent |
On a film’s page the button only exists for single titles: not series, not bundles. Episodes download one at a time from their own rows.
tap │ ├─ not logged in? ─────────────────────► AuthBottomSheet ├─ not subscribed? ────────────────────► SubscriptionBottomSheet ├─ plan has allow_video_download=false? ► DownloadBottomSheet.showUnavailable │ ├─ current status is downloading or completed? │ └──────────────────────────────► DownloadBottomSheet.show │ ("Cancel Download" or │ "Remove from download") │ └─ status is absent, queued, paused or failed: ├─ ContentHelper.checkDownloadSettings │ "Download over Wi-Fi only" is on and we're not on Wi-Fi? │ └──► "Waiting For WiFi" sheet, nothing starts ├─ ContentHelper.checkDownloadLimit (new titles only) │ already 50 downloads that have not failed? │ └──► "Download limit reached" sheet ├─ ContentHelper.checkDownloadStorage │ estimated size bigger than the free space? │ └──► "Not enough storage" sheet │ ├─ paused or failed ──► ResumeContentEvent └─ absent or queued ──► DownloadContentEvent / DownloadEpisodeTwo details are easy to miss:
- The status comes from a live stream, not a lookup. Each button is
wrapped in a
DownloadRowWatcher(doc 3), so the icon and the tap decision always reflect the latest record. - The storage check estimates before it blocks. It fetches the master
playlist, takes the chosen rendition’s
BANDWIDTH, multiplies it by the title’s duration (less whatever is already downloaded), adds a 50 MB margin and compares that with the device’s free space. Anything it cannot work out — no duration, no bandwidth, a manifest fetch slower than 3 seconds, any error — lets the download go ahead. - The 50-download limit is a client-side rule. It counts the downloads this account can see that have not failed. Nothing in the subscription data carries a limit.
Step 2: the bloc translates events into manager calls
Section titled “Step 2: the bloc translates events into manager calls”DownloadScreenBloc is a thin adapter kept for older widgets. It owns no
download state:
| Event | What it calls |
|---|---|
DownloadContentEvent |
DownloadManager.start(content), then emits ContentDownloaded(download_enqueued) or DownloadScreenError |
DownloadEpisode |
start(content), then reloads the list |
ResumeContentEvent |
if offline: error. No record: start. Record failed: retry. Otherwise: resume |
CancelContentEvent, Remove… events |
cancel(contentId) |
RemoveAllContentEvent |
cancel for every record |
The index arguments on the older events are accepted and ignored.
Everything is keyed by contentId.
Step 3: DownloadManager.start
Section titled “Step 3: DownloadManager.start”DownloadManagerImpl.start(ContentEntity) is where a tap becomes durable
state:
start(content) │ ├─1─ row exists and is "completed"? → return it, nothing to do ├─2─ row exists and is "downloading"/"queued"? → return it (idempotency) │ ├─3─ first download only (Android): │ • ask for the battery-optimization exemption (once) │ • on aggressive OEMs, show the "keep downloads running" tip (once) │ ├─4─ create the Hive row (status "queued", totalSegments 0), or reset an │ old row to "queued" with a fresh settlement budget ├─5─ publish the record to the UI streams ├─6─ start the Android progress notification NOW, while the app is │ certainly in the foreground (it shows an indeterminate bar) ├─7─ iOS only: configure the per-title system notification │ ├─8─ new row → orchestrator.start(row) (plan + enqueue all) │ existing row → orchestrator.reenqueueMissing(row) (only missing files) │ ├─9─ row = "downloading", publish, refresh the notification with real totals │ (if nothing was missing, finalize right away) │ └─✗─ any exception: a row still "queued" becomes "failed" with the error, and the notification is re-synced so it can stopWhy each guard exists:
- Step 2 idempotency. A double tap would otherwise enqueue the whole title twice. The package does not deduplicate tasks by ID, so both copies would download.
- Step 6 before step 8. Parsing and enqueueing thousands of tasks takes noticeable time. Android 12+ refuses to start a foreground service once the app is in the background, so the service has to start before the user can walk away (doc 5).
- The failure branch. A row stuck at
queuedwould be caught by the step 2 guard forever, and the user could never start that title again. Marking itfailedturns the button back into “try again”.
The row is a CacheContentEntity (all fields in doc 7). At this point it
holds:
- the title
- the slug
masterUrl- poster and thumbnail URLs
- subtitle references
episodeMetaJson: theContentEntityfields the offline UI needs later, encoded byContentEntitySerde.encode, so the Downloads screen can show the title with no network
Step 4: the orchestrator makes a plan
Section titled “Step 4: the orchestrator makes a plan”DownloadOrchestrator.start serializes concurrent calls per title with an
in-flight map, then runs _startImpl:
- Create
<Documents>/<slug>/. - Reuse a saved plan if the row has one (
cachedPlanJson). Otherwise callHlsManifestParser.parse(masterUrl, targetResolution: …). - Keep the plan in an in-memory cache.
- Set
row.totalSegments = plan.totalRequiredCountand save the plan JSON on the row. From here on the title’s makeup is known without the network. - Build the tasks and enqueue them (steps 6 and 7 below).
Which quality gets downloaded
Section titled “Which quality gets downloaded”The target comes from the Download quality setting:
| Setting | downloadQuality pref |
Target height |
|---|---|---|
| first option | 0 |
1080 |
| second option (default) | 1 |
720 |
| third option | 2 |
360 |
AppBootstrapImpl._restoreDownloadPreferences copies the pref into the
global selected_download_quality at startup. The settings screen updates
both.
DownloadOrchestrator._downloadResolution then caps the target to what the
account may stream:
target = selected_download_qualityif ContentHelper.isHdAllowed() → keep target (privileged user, or plan max_resolution = 0)else if getMaxVideoResolution() < target → use that limit insteadon any error → keep targetHow the parser picks a rendition
Section titled “How the parser picks a rendition”HlsManifestParserImpl.parse:
GETthe master playlist. Any status other than 200 throws.variantsFromMasterreads every#EXT-X-STREAM-INFline and the URI on the line after it. It drops entries with noRESOLUTION(audio-only renditions) and sorts by height, then bandwidth, best first.pickVariant(variants, target)takes the tallest rendition not taller than the target. If every rendition is taller, it takes the smallest.
ladder in master: 1080p 720p 360p target 720 → 720p target 480 → 360p widescreen ladder: 1920x804 1280x536 target 720 → 1280x536Matching by height rather than an exact RESOLUTION=1280x720 string is
what lets widescreen masters work.
GETthe chosen variant playlist:- every non-comment line becomes an
HlsSegment: an absolute URL resolved against the playlist, plus its basename as the local filename - the first
#EXT-X-KEYURIbecomes theHlsKeyPlan
- every non-comment line becomes an
- Audio renditions.
#EXT-X-MEDIA:TYPE=AUDIOlines are kept if they are in the variant’sAUDIOgroup (or all of them, if it names none) and have a URI.- Each gets a folder name: the parent directory in its URL (for example
audio_bn), otherwise its language, otherwiseaudio_<n>. - Its playlist is fetched. A failed audio fetch is silently skipped.
- Each gets a folder name: the parent directory in its URL (for example
- Return an
HlsParsedPlanholding:- the master URL and raw text
- the chosen
#EXT-X-STREAM-INFline (reused later for the local master) - the video plan
- the audio plans
The count the manager tracks is:
totalRequiredCount = video segments + all audio segments + (key ? 1 : 0)Thumbnails, posters and subtitles are downloaded, but they never count toward completion. A missing poster does not make a film unplayable.
The parser uses plain package:http with no headers: playlists and
segments on the CDN are public. The key is the only request that needs
signed headers.
Step 5: turning the plan into tasks
Section titled “Step 5: turning the plan into tasks”DownloadTaskFactory.build creates one DownloadTask per file. Every task
looks like this:
DownloadTask( taskId: content_<contentId>_<kind>_<index> e.g. content_42_video_17 group: content_<contentId> url: absolute URL from the plan filename: segment basename | key.bin | thumbnail.jpg | poster.jpg | <sub>.vtt directory: <slug> or <slug>/<audioFolder> baseDirectory: applicationDocuments updates: status only (no byte progress) retries: 3 allowPause: true displayName: the title headers: only on the key task: EncryptionHelper().playerHeader() metaData: {"contentId", "kind", "index", "totalRequired", + "audioGroup"/"audioFolder" for audio, + "language"/"label" for subtitles})| Field | Why it is set this way |
|---|---|
Deterministic taskId |
The same file always gets the same ID. Audio adds its rendition’s position (…_audio_<rendition>_<index>), so two audio tracks cannot collide. |
One group per title |
cancelAll(group:) stops a whole title in one call. Pause, cancel, failure and post-completion cleanup all rely on it. |
Relative directory + applicationDocuments |
Survives the iOS container path changing on reinstall. |
updates: status |
With thousands of tasks, per-byte progress events would flood the Dart side. Progress is counted in finished files instead. |
retries: 3 |
The package retries a failed file after 2, 4 and 8 seconds. Those retries run on the Dart side (doc 4). |
allowPause: true |
Lets Android resume a transfer that hit the package’s 9-minute worker limit with a Range request instead of starting over. |
metaData JSON |
How a status update is routed back to its title and kind, with no lookup table. |
headers on the key only |
The KMS refuses a key request without the signed referer, vpsid and platform headers. The CDN needs nothing. |
The order is: all video segments, then each audio rendition’s segments, then the key, thumbnail, poster and subtitles.
Re-queue paths do not compare task IDs. They compare each task’s
destination (directory/filename), so files queued by an older build under
different IDs are still recognised as in flight (doc 4).
DownloadTaskFactory.buildMissing builds the same kind of tasks, but only
for the video segments, audio segments and key whose names are in a
“missing on disk” set. Art and subtitles are not rebuilt. It is used by
every re-queue path.
Step 6: enqueueing in checked chunks
Section titled “Step 6: enqueueing in checked chunks”DownloadOrchestrator._enqueueChecked hands the tasks over in chunks of
200:
tasks (e.g. 1,804) │ ├─ enqueueAll(first 200) → [true, true, false, …] ─┐ ├─ enqueueAll(next 200) → [true, …] ├─ collect every false ├─ … ─┘ │ ├─ retry all failures once, again in chunks └─ still failing → log "[orch] N/M tasks failed to enqueue after retry; settlement will recover them"Why chunks and per-task flags:
- One huge
enqueueAllis slow to serialize across the platform channel. - Individual rejections used to vanish silently, leaving holes that only the stall watchdog noticed minutes later.
Anything that still fails is not an error: the file is simply missing on disk, and settlement (doc 4) will queue it again.
Step 7: what the native side does with them
Section titled “Step 7: what the native side does with them”The details are in docs 5 and 6. In short:
ANDROID iOS enqueueAll enqueueAll │ │ ▼ ▼ holding queue (in the app process) background URLSession │ releases 8 at a time; the next one every task created and │ goes out as each finishes resumed immediately; ▼ nsurlsessiond (a system WorkManager job per task daemon) decides how many │ network constraint: connected run at once ▼ │ DownloadTaskRunner: GET → temp file under ▼ the app's files dir → move into <slug>/… file moved into <slug>/… │ │ └──────► "complete" status update ◄───────────┘Each task reports enqueued straight away, running when it starts, and
finally complete, failed, notFound (HTTP 404) or canceled. Doc 3
picks up from those updates.
Where this lives in the code
Section titled “Where this lives in the code”lib/presentation/widgets/content_page_widget/content_page_button_widget.dart:_onTapDownload,_titleForStatus.lib/presentation/pages/content_pages/single_content_screen.dart: the episode download handler.lib/presentation/pages/library_pages/download_screen.dart:_onRowDownloadTap.lib/data/helpers/content_helper.dart:checkDownloadSettings,checkDownloadStorage,isHdAllowed,getMaxVideoResolution.lib/presentation/bloc/download_screen/download_screen_bloc.dart: event translation.lib/data/services/download_manager_impl.dart:start,_maybePromptBatteryOptimization,_failIfStillQueued.lib/data/services/hls/download_orchestrator.dart:start,_startImpl,_downloadResolution,_enqueueChecked.lib/data/services/hls/hls_manifest_parser.dart:parse,variantsFromMaster,pickVariant.lib/data/services/hls/download_task_factory.dart:build,buildMissing,groupFor,taskIdFor.
The takeaway
Section titled “The takeaway”Starting a download does four durable things quickly:
- It writes a Hive row.
- It pins the notification while the app is still in front.
- It saves a complete plan of the title.
- It hands every file to the native queue under a predictable ID.
Every later stage relies on those IDs and that saved plan, so it can always answer “what does this title need, and what is already done?” from local state alone.