Skip to content

3. Progress and Completion

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

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 complete for a counted kind adds one. The tracker remembers which files it has counted (by directory/filename), so a second complete for 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. scheduleReconcile starts at most one timer per title. After 15 seconds it recursively counts the .ts, .m4s and key.bin files under <Documents>/<slug> and raises the counter if the disk has more. reconcileNow does 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:

  • seedCounter sets a starting value on cold start (doc 4).
  • resetCounter may 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"? → stop
completed = tracker counter, progress = completed / totalSegments
nothing moved and row is "downloading" or "failed"? → stop
counter moved forward? → settlementAttempts = 0
row.completedSegments = completed, row.progress = progress
row 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; _maybeSettle

Three 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. _scheduleSave coalesces saves to one per second per title. State changes that must survive a crash, such as status transitions, save immediately.

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.

On Android, _syncAggregateNotification turns all active titles into one notification:

  • Active means queued, or downloading with 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).

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"
notification

_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”
  1. Find the row. If it is already completed with a local master path, stop.
  2. 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.
  3. LocalPlaylistWriter.writeAll(assetRoot, plan).
  4. 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.m3u8

The key-URI rewrite reuses CastPlaylistRewriter.keyUriPattern, so the download writer and the cast proxy share one definition of that pattern.

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 player

Casting 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
  • 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.dart and lib/presentation/bloc/download_screen/download_screen_bloc.dart: the consumers.
  • lib/data/services/content_entity_serde.dart: rebuilding a ContentEntity offline.

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.