3. Progress and Completion
3. Progress and Completion
Section titled “3. Progress and Completion”Where we left off
Section titled “Where we left off”Doc 2 ended with every file of a title queued natively. Each task will
eventually report complete, failed, notFound or canceled. This doc
follows those reports:
- how they become a moving progress bar
- how the app decides a title is truly finished
- what it writes to disk to make the download playable
Status updates arrive in Dart
Section titled “Status updates arrive in Dart”DownloadManagerImpl._initialize subscribes to FileDownloader().updates
before calling FileDownloader().start(). The order matters: start()
replays updates that arrived while the app was suspended or killed, and a
listener attached later would miss them.
Every update goes through _onUpdate:
TaskStatusUpdate(task, status) │ ├─ decode task.metaData JSON → contentId, kind │ (not our JSON, or no contentId) → ignore ├─ look up the row in the in-memory map (_rowById) │ (no row: cancelled or unknown) → ignore │ (row is "cancelling") → ignore ▼_onStatusUpdate(row, update, kind)_onStatusUpdate only reacts to final outcomes. enqueued, running
and waitingToRetry pass through untouched:
| Update | Kind | What happens |
|---|---|---|
complete |
video, audio, key |
Remove from the miss set, count it (AssetProgressTracker.onSegmentCompleted), schedule a disk recount |
complete |
thumbnail |
row.localThumbnailRelativePath = "<slug>/<filename>", save soon |
complete |
poster |
row.localPosterRelativePath = "<slug>/<filename>", save soon |
complete |
subtitle |
Find the subtitle by URL or filename, set its localRelativePath, save soon |
failed, notFound, canceled |
counted kinds | Ignored while the row is paused or failed. Otherwise the task ID joins the miss set and _maybeSettle checks whether everything has now finished (doc 4) |
| anything else | any | ignored |
The paused guard matters because pausing works by cancelling the title’s
tasks. The canceled updates that follow are the pause taking effect, not
files that went missing.
Counting finished files: AssetProgressTracker
Section titled “Counting finished files: AssetProgressTracker”The tracker holds one counter per title and has two sources of truth:
┌───────────────────────────────┐ events ───►│ onSegmentCompleted(id, task) │ +1 per task ID, never past the total │ │ │ counter[contentId] │──► changes stream (contentId) │ │ disk ───►│ scheduleReconcile / reconcileNow │ raise to the on-disk count └───────────────────────────────┘- Events. Each
completefor a counted kind adds one. The tracker remembers which files it has counted (bydirectory/filename), so a secondcompletefor the same file is ignored. The package does not deduplicate queued tasks, so a file can occasionally download twice, and counting it twice would push the counter to the total while files are still missing. - Disk.
scheduleReconcilestarts at most one timer per title. After 15 seconds it recursively counts the.ts,.m4sandkey.binfiles under<Documents>/<slug>and raises the counter if the disk has more.reconcileNowdoes the same immediately and returns the counter. Concurrent calls for one title share a single scan. The disk path catches files that finished while Dart was not listening.
The counter only moves forward. There are two exceptions, and neither emits a change:
seedCountersets a starting value on cold start (doc 4).resetCountermay move it down when a disk check shows it overstated progress (see “The disk check” below).
forget clears everything for a title when it is cancelled.
The disk count is only an estimate: it counts by file extension and does not know which names the plan expects. The authoritative check, by name, happens at completion.
From a count to a progress bar: _onTrackerChange
Section titled “From a count to a progress bar: _onTrackerChange”Every change on the tracker’s stream reaches _onTrackerChange(contentId):
row missing, "cancelling" or "completed"? → stopcompleted = tracker counter, progress = completed / totalSegmentsnothing moved and row is "downloading" or "failed"? → stopcounter moved forward? → settlementAttempts = 0row.completedSegments = completed, row.progress = progressrow is not "paused"/"failed" → status = "downloading"remember "last advanced now" (feeds the stall watchdog)save the row (debounced to once per second)publish the record (per-title stream + debounced all-records stream)│├─ completed ≥ total → _safeFinalize(contentId)└─ otherwise → refresh the Android notification; _maybeSettleThree details matter:
- A failed row still updates its numbers. It does not flip back to
downloading. The Downloads list stays accurate while the row waits for the user to tap Retry. - Progress refills the settlement budget. A long download on a bad network can recover many times, as long as it keeps moving between recoveries (doc 4).
- Saves are debounced. Writing the Hive row on every one of thousands
of events would be wasteful.
_scheduleSavecoalesces saves to one per second per title. State changes that must survive a crash, such as status transitions, save immediately.
Publishing to the UI
Section titled “Publishing to the UI”The manager exposes records as streams, never as mutable objects:
| API | Backing | Behaviour |
|---|---|---|
watch(contentId) |
one BehaviorSubject<DownloadRecord?> per title |
Created on first use and seeded with the current record, or null if there is no row or the row belongs to another account or exceeds the profile’s maturity level (doc 7). Emits on every change to that title. Never closed: on cancel it emits null, so a widget subscribed before the cancel still sees a later re-download. |
watchAll() |
one BehaviorSubject<List<DownloadRecord>> |
Every row the signed-in account and profile may see. Bursts of changes are coalesced to one emission per 250 ms. |
get(contentId), list() |
the in-memory map and the Hive box | One-off snapshots. |
Two consumers sit on top:
DownloadManager.watch(id) ──► DownloadRowWatcher ──► builder(context, record) (one per button / row; stream cached in a late final field so rebuilds don't resubscribe)
DownloadManager.watchAll() ──► DownloadScreenBloc._convertRecords ├─ relative paths → absolute paths ├─ status → legacy strings ("downloading", …) ├─ progress → integer percentage └─ ContentEntitySerde.decode(episodeMetaJson) ──► DownloadScreenLoaded(List<ContentEntity>)DownloadRowWatcher passes null when no row exists. Builders must treat
“no record” (show a plain Download button) differently from queued (show
“waiting”). The DownloadRecordX extension offers isAbsent,
isDownloading, progressOrZero and similar helpers.
DownloadScreenBloc converts asynchronously the first time, while it
resolves the documents path, so two rapid emissions could finish out of
order. Each conversion takes a sequence number, and only the newest one may
publish. Without that guard a cancelled title could stay visible.
The Android notification keeps pace
Section titled “The Android notification keeps pace”On Android, _syncAggregateNotification turns all active titles into one
notification:
- Active means
queued, ordownloadingwith a known total. - Progress is the sum of finished files over the sum of totals.
- Title is the film’s name for one download, or “Downloading N items”.
DownloadProgressNotifier.updateRunning only posts when the whole
percentage changes, unless the call is forced. When nothing is active it
stops the service. Doc 5 covers the service; iOS uses a different
notification model (doc 6).
Declaring completion: _safeFinalize
Section titled “Declaring completion: _safeFinalize”When the counter reaches the total, _safeFinalize(contentId) runs. Several
paths can trigger it at nearly the same time:
- a tracker change
- a settlement pass
- the app returning to the foreground
- cold start
So it holds a per-title mutex. A second caller waits for the first and returns.
_safeFinalize(contentId) │ ├─1─ disk check by name (_resumeIfIncomplete) │ incomplete → fix the counter, keep downloading, re-queue, STOP │ ├─2─ orchestrator.finalize(contentId) │ writes the local playlists, row = "completed" │ ├─3─ cancelAll(group) stragglers or duplicates still queued natively ├─4─ publish the record, show "Download complete" (Android) ├─5─ refresh the notification (stops the service if nothing else is active) │ └─✗─ exception in 2 → row = "failed" with the error, "Download failed" notificationThe disk check
Section titled “The disk check”_resumeIfIncomplete(row) asks the orchestrator how many files the
plan expects that are not on disk:
missingOnDisk(row): for every video segment: <slug>/<filename> exists? for every audio rendition segment: <slug>/<folder>/<filename> exists? if the plan has a key: <slug>/key.bin exists? → number missing (or null when no saved plan exists)| Result | Action |
|---|---|
null (no saved plan) |
Proceed. finalize re-parses the manifest itself. |
0 |
Proceed to finalize. |
> 0 and row paused |
Stop, and leave the paused download alone. |
> 0 otherwise |
Log finalize deferred … N files missing on disk. Reset the counter and row to the true on-disk count, set status downloading, publish, then reenqueueMissing. Do not finalize. |
This check is what prevents a “completed” title that stops playing halfway through. The counter is a fast estimate; only the file-by-file check may decide a title is complete.
Writing the playlists: orchestrator.finalize
Section titled “Writing the playlists: orchestrator.finalize”- Find the row. If it is already
completedwith a local master path, stop. - Get the plan from the in-memory cache or the saved JSON. As a last resort, re-parse with two attempts of 30 seconds each, 2 seconds apart, and save the result back to the row.
LocalPlaylistWriter.writeAll(assetRoot, plan).- Update the row:
localMasterRelativePath = "<slug>/master.m3u8",status = "completed",progress = 1.0,completedSegments = totalSegments, no error. Save.
LocalPlaylistWriterImpl produces three kinds of file:
video.m3u8 = the variant playlist as downloaded, with • every segment line replaced by the local filename, in order • the #EXT-X-KEY URI replaced by "key.bin"
<folder>/media.m3u8 (one per audio rendition) = the same rewrite
master.m3u8 = written from scratch: #EXTM3U #EXT-X-VERSION:3 (4 when there is audio) #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID=…,NAME=…,DEFAULT=…, [AUTOSELECT=YES],[LANGUAGE=…],CHANNELS="2", URI="<folder>/media.m3u8" (one per rendition) <the saved #EXT-X-STREAM-INF line> video.m3u8The key-URI rewrite reuses CastPlaylistRewriter.keyUriPattern, so the
download writer and the cast proxy share one definition of that pattern.
After completion: playback
Section titled “After completion: playback”A completed row now has a relative master path. Tapping it on the Downloads
screen calls CastAwareNavigation.play(context, content, isOffline: true),
which leads to:
VideoPlayerScreen._initializeProxyServer └─ _toRelativeAssetPath(absolute path) → "<slug>/master.m3u8" └─ LocalPlaybackServerController.resolvePlaybackUrl └─ LocalProxyServer bound to <Documents>/<slug> on 127.0.0.1:8080 → "http://127.0.0.1:8080/master.m3u8" handed to the native playerCasting an offline title is refused with an explanation, because the TV
cannot reach the phone’s loopback address.
../playback/03-offline-downloads.md explains:
- why playback goes through a local server rather than a file path
- why the key must be served as
application/octet-stream - how the audio playlists find the single root
key.bin
Where this lives in the code
Section titled “Where this lives in the code”lib/data/services/download_manager_impl.dart:_onUpdate,_onStatusUpdate,_onTrackerChange,_syncAggregateNotification,_safeFinalize,_resumeIfIncomplete,watch,watchAll,_scheduleSave,_emitAll.lib/data/services/hls/asset_progress_tracker.dart: counter, deduplication, disk scans.lib/data/services/hls/download_orchestrator.dart:missingOnDisk,finalize,_findMissingOnDisk,_parseWithRetry.lib/data/services/hls/local_playlist_writer.dart: the playlist rewrites.lib/presentation/widgets/download/download_row_watcher.dartandlib/presentation/bloc/download_screen/download_screen_bloc.dart: the consumers.lib/data/services/content_entity_serde.dart: rebuilding aContentEntityoffline.
The takeaway
Section titled “The takeaway”Progress is a cheap, deduplicated count of finished files, kept honest by periodic disk counts and published as streams the UI simply redraws from. Completion is a separate, deliberate step: a per-title lock, a file-by-file comparison of the saved plan against the disk, and only then the playlists that make the folder playable. The counter may be wrong for a moment; the disk check decides.