Course Progress in Flutter: The Player Position Is Not Progress
A progress bar that disagrees with itself across two devices, and a lesson download that expires mid-flight. Storing position so it can merge, queueing writes offline, and where a downloaded video actually belongs on disk.
A learner finishes lesson four on their phone on the train, opens the course on a tablet that evening, and the app offers to start them at lesson two. Nothing crashed. No request failed. Both devices are showing exactly what they were told, and one of them is a week out of date.
The position of a video player is a number that changes sixty times a second. Progress is a fact about a person. Storing the first and calling it the second is where course apps go wrong. One is scrubbing noise that resets when a widget rebuilds; the other has to survive a reinstall, a second device and a flight with no signal.
Which lessons a learner is entitled to at all is the subscriptions problem and has its own post. This one is about the two things a course app owes them once they are in: a progress number that is the same everywhere, and a lesson that still plays when the network does not.
Three different numbers, one progress bar
There are three quantities in a course app and they are routinely stored as one. Player position is where the playhead is right now, in seconds, and it is worth persisting only so the resume button has somewhere to go. Lesson completion is a boolean with a timestamp — it happened, and it stays happened. Course percent is derived from completions, and should never be stored at all.
Storing course percent is the tempting one, because it is what the UI shows and computing it looks wasteful. It is also the one that goes stale the first time an instructor adds a lesson to a published course: every learner's stored 100% is now a lie, and there is no event you can hook to go and fix it. Count completions against the current lesson list at read time and the number is never wrong.
Position is the opposite: it is high-frequency and low-value. Writing it on every player tick is a write per frame; writing it only on dispose loses it when the app is killed from the recents list, which on a phone is most of the time. Throttle to roughly every five seconds of playback, and write once more on pause and on backgrounding.
Completion only moves one way
Ninety per cent watched is the usual threshold, and it is a decision rather than a standard — credits and outros mean a lesson genuinely ends before the file does. What matters more than the number is that the rule is monotonic. A learner who reaches 92%, then reopens the lesson to check something at the two-minute mark, has not become less finished.
This is the bug that a naive implementation produces on day one, because the obvious code sets completed = position / duration > 0.9 on every tick, and that expression is false as soon as they scrub backwards. Completion is set, never cleared, and only an explicit reset from the learner takes it away.
The other half is that a seek is not a watch. If the only evidence you keep is the furthest point reached, dragging the scrubber to the end completes every lesson in the course in about eight seconds. If completion means anything to you — a certificate, a batch report, an instructor's dashboard — then accumulate watched seconds while the player is actually playing, and compare that to the duration instead.
await db.execute('''
CREATE TABLE lesson_progress (
lesson_id TEXT PRIMARY KEY,
position_secs INTEGER NOT NULL DEFAULT 0,
watched_secs INTEGER NOT NULL DEFAULT 0,
completed_at INTEGER,
updated_at INTEGER NOT NULL,
dirty INTEGER NOT NULL DEFAULT 1
)
''');
// Every field takes the larger value, so an out-of-order write
// from a slow device cannot walk progress backwards. completed_at
// uses COALESCE on the existing value: once set, it stays set.
Future<void> record(LessonProgress p) => db.rawInsert('''
INSERT INTO lesson_progress
(lesson_id, position_secs, watched_secs, completed_at, updated_at, dirty)
VALUES (?, ?, ?, ?, ?, 1)
ON CONFLICT(lesson_id) DO UPDATE SET
position_secs = MAX(position_secs, excluded.position_secs),
watched_secs = MAX(watched_secs, excluded.watched_secs),
completed_at = COALESCE(completed_at, excluded.completed_at),
updated_at = MAX(updated_at, excluded.updated_at),
dirty = 1
''', [
p.lessonId, p.positionSecs, p.watchedSecs,
p.completedAt?.millisecondsSinceEpoch, p.updatedAt.millisecondsSinceEpoch,
]);Two devices means merge, not overwrite
Last-write-wins is the default you get for free, and it is wrong here in a way learners notice. The tablet that was open on lesson two writes last, so it wins, and the four lessons finished on the phone that morning are gone. The two devices were never in conflict about anything — one simply knew more.
The fix is that the server applies the same merge rule as the device, rather than accepting whatever arrived most recently. Because every field in this table is monotonic, the merge is a one-liner and it needs no vector clocks, no version numbers and no conflict UI.
create table lesson_progress (
user_id uuid not null references profiles(id) on delete cascade,
lesson_id uuid not null references lessons(id) on delete cascade,
position_secs integer not null default 0,
watched_secs integer not null default 0,
completed_at timestamptz,
updated_at timestamptz not null default now(),
primary key (user_id, lesson_id)
);
-- The same rule as the client. Order of arrival stops mattering,
-- which means a retry of a queued write is harmless and a device
-- that has been offline for a week cannot undo anything.
insert into lesson_progress as lp
(user_id, lesson_id, position_secs, watched_secs, completed_at, updated_at)
values ($1, $2, $3, $4, $5, $6)
on conflict (user_id, lesson_id) do update set
position_secs = greatest(lp.position_secs, excluded.position_secs),
watched_secs = greatest(lp.watched_secs, excluded.watched_secs),
completed_at = coalesce(lp.completed_at, excluded.completed_at),
updated_at = greatest(lp.updated_at, excluded.updated_at);Making every write idempotent and order-independent is what turns sync from a hard problem into a boring one. Retries are free, duplicates are free, and the queue does not have to preserve ordering.
Write locally first, sync on a queue
The local table above is the source of truth for the UI, and the server is something it reconciles with. That ordering is the whole design: the progress bar updates from the database the app just wrote to, so it never depends on a network round trip, and a learner in a tunnel sees their lesson tick over exactly as they would on wifi.
The dirty flag is the queue. A sync pass takes every dirty row, posts them in one batch, and clears the flag on the rows the server acknowledged. Nothing is deleted on failure, so a pass that dies halfway simply runs again. The same argument, with Room and coroutines instead of sqflite, is the offline-first post — the storage layer differs and the shape does not.
Two things worth getting right in the pass itself. Clear dirty only for rows whose updated_at has not moved since you read them, or a lesson watched during the upload loses its flag and never syncs. And run the pass on resume as well as on a timer, because the interesting transition is backgrounded-with-no-signal to foregrounded-on-wifi, and no timer fires while the app is asleep.
A signed URL is not a download
Course video sits behind signed URLs, and the URL carries its own expiry. That makes it a temporary credential, and it should be treated like one: minted at the moment of playback, never stored in a table, never cached in the lesson model.
Caching it is the mistake that produces the strangest bug report in this category. The lesson list is fetched, the URLs inside it are good for an hour, the learner works through the course, and lesson nine will not play — with an error from the video player about a format it cannot read, because what came back was an XML access-denied document. There is nothing wrong with lesson nine.
Future<Uri> playbackUrl(Lesson lesson) async {
// Offline first: if the file is on disk it is already paid for,
// already authorised, and needs no network at all.
final local = await downloads.fileFor(lesson.id);
if (local != null) return local.uri;
// Otherwise mint one now. Short-lived on purpose — long enough
// to start playback, not long enough to paste into a group chat.
final res = await api.post('/lessons/' + lesson.id + '/playback');
return Uri.parse(res.url);
}Store the object key on the lesson row and mint from it. The key is stable, shareable within your own backend, and safe to log; the URL is none of those things.
Downloads: resumable, and somewhere Apple approves of
A lesson download is tens or hundreds of megabytes over a mobile connection, which means it will be interrupted. Restarting from zero each time is not merely rude, it is a download that never finishes on a commute. HTTP has the answer built in: send a Range header for the bytes you are missing and append to the partial file.
final partial = File(target.path + '.part');
final have = await partial.exists() ? await partial.length() : 0;
final req = http.Request('GET', url);
if (have > 0) {
req.headers['Range'] = 'bytes=$have-';
// If the object changed since the partial was written, the
// server ignores the range and sends the whole file instead of
// silently handing back bytes that belong to a different video.
req.headers['If-Range'] = storedEtag;
}
final res = await client.send(req);
// 206 means the range was honoured, so append. 200 means it was
// not — the server does not support ranges, or If-Range failed —
// and the only correct move is to start the file again.
final sink = partial.openWrite(
mode: res.statusCode == 206 ? FileMode.append : FileMode.write,
);
await res.stream.pipe(sink);
await partial.rename(target.path);The rename at the end is not tidiness. A file that only takes its real name once it is complete cannot be picked up half-written by the player after a crash, and it makes "is this lesson downloaded?" a question you answer with a single existence check rather than by comparing a length against a manifest.
Where the file goes is an App Review question as much as a technical one. Apple's data storage guidelines are explicit that only user-generated content that cannot be recreated belongs in Documents, and that re-downloadable content stored there must be marked with the do-not-back-up attribute. A gigabyte of course video silently uploading itself to somebody's iCloud account is a rejection and, worse, a one-star review about the app filling their storage. Use the application support directory and set the exclusion flag. On Android the equivalent is keeping downloads out of the auto-backup rules, for the same reason.
And downloads have to be revocable. When a subscription lapses or a learner is unenrolled from a batch, the files come off the device on the next launch. If that path does not exist, the offline feature is a one-time purchase of your entire catalogue.
You can stop a casual user, not a determined one
It is worth being straight about this, because it is oversold constantly in this category. A downloaded video that plays without a network is a file, and on a rooted or jailbroken device a file can be copied. Signed URLs stop hotlinking. Encrypting the file at rest and holding the key behind an entitlement check stops casual copying and raises the effort considerably. Neither is DRM.
Real offline DRM means Widevine on Android and FairPlay on iOS, with persistent licences, a licence server and packaged content — a different pipeline, a different hosting bill, and platform code on both sides. That is a reasonable thing to build when your content is worth it. It is not a thing a template ships, and any listing that claims DRM while shipping an mp4 in application support is describing something it does not have.
For most course products the honest answer is encryption at rest plus a watermark carrying the learner's account, which changes the incentive rather than the possibility. Decide which of those you are doing before you write the store listing.
Test it in aeroplane mode, on the second device
The test that catches all of this takes four minutes and no tooling. Download two lessons, turn on aeroplane mode, watch one of them to the end and half of the other, force-quit the app, reopen it. Progress should be exactly where you left it and both videos should still play. Then turn the network back on, wait for a sync pass, and open the course on a second device signed into the same account.
Everything above is a way of passing that walk-through, and every course app that has annoyed you failed one of its steps. Store facts, merge instead of overwriting, and treat the network as something that comes back rather than something that is there.
Coachly — $40
A Flutter student app and a Next.js admin where courses, secure video lessons, progress tracking and the instructor side are already built — batches, mock tests and payments included.