I build Vidkap, a video downloader app for Android and a video downloader extension for Chrome. Both do the same job: find the videos a web page actually loads and save them as a normal file you can keep.
A plain MP4 link is easy. Most video on the web isn't one, though. It's HLS or DASH: a playlist that points at hundreds of small segments, sometimes encrypted, sometimes with audio and video as separate streams. To give the user one playable file, you have to download all of it and put it back together.
The usual Flutter answer is to bundle ffmpeg. I didn't, for three reasons:
- It adds tens of megabytes to the app.
- FFmpegKit, the common Flutter wrapper, was retired in 2025.
- I wanted full control over progress, cancellation and resuming.
The Chrome extension already had a JavaScript engine for this, so I ported it to Dart. It's now about 13,000 lines and goes from playlist to playable MP4 without any native media library. Here's how it fits together, and the parts that surprised me.
The pipeline
manifest (HLS / DASH)
→ segment list
→ parallel downloads (retry, timeout, cancel, Range resume)
→ fed to the demuxer in order
→ demux (MPEG-TS / fMP4) into samples
→ sample bytes in a spool file on disk
→ MP4 mux, moov first
Every download job runs in its own isolate. The UI isolate only receives progress messages, so the engine can use plain synchronous file I/O without janking the UI. If a job doesn't respond to cancel (say, in the middle of a long write), the host simply kills the isolate.
1. Parsing the playlists
HLS has two levels: a master playlist that lists the variants, and media playlists that list the segments. Real-world playlists use everything RFC 8216 allows: byte ranges, EXT-X-MAP init segments, discontinuities, live windows, and separate audio renditions that have to be downloaded as a second input. When picking the "best" variant, I sort by height, then bandwidth, then frame rate.
DASH is XML with three ways to describe segments: SegmentTemplate (with or without SegmentTimeline), SegmentList and SegmentBase. The last one means downloading the sidx box first and turning it into byte ranges. Segment information is also inherited down Period → AdaptationSet → Representation, and that inheritance is where most of my DASH bugs lived.
Both parsers check for DRM and stop there. Widevine, FairPlay and SAMPLE-AES content is detected and refused by design.
2. Downloading in parallel, feeding in order
Segments download in parallel (four at a time by default), but the demuxer needs them in order. A bounded window keeps a fast network and a slow disk from filling memory with finished segments that are still waiting their turn. Simplified:
// Start another download only while we're under the concurrency limit and
// downloaded-but-not-yet-consumed segments stay within the window.
while (queue.isNotEmpty &&
inflight < job.concurrency &&
inflight + buffered < window) { // window = concurrency * 2
start(queue.removeAt(0));
}
Each request carries the cookies, User-Agent and Referer of the page in the WebView, because many CDNs check them. That caused my favorite network bug: some CDNs reject a Referer from a different site with a 403, and some pages send no Referer at all (<meta name="referrer" content="no-referrer">). The fix: if a job gets a 403 before a single media byte arrives, it's retried once without the Referer.
3. AES-128
Standard HLS encryption is AES-128-CBC over the whole segment, with PKCS#7 padding. The IV is either given explicitly or derived from the segment's media sequence number. Pointycastle handles the block cipher; the interesting part is the padding.
Not every server pads correctly, and a decrypted segment can end in bytes that merely look like valid padding. MPEG-TS gives you a way to tell: packets are exactly 188 bytes, so a properly padded segment can never be a multiple of 188 bytes long.
// A padded TS segment can never have an encrypted length that's a multiple
// of 188 bytes. If it is, the "padding" is a coincidence: keep every byte.
if (data.length % 188 == 0 && out[0] == 0x47) return out;
return Uint8List.sublistView(out, 0, out.length - pad);
Keys are fetched once per URL and cached, so a playlist with 800 segments and one key makes one key request.
4. Demuxing MPEG-TS without running out of memory
A TS segment is a stream of 188-byte packets. The demuxer reassembles them into PES packets, splits the video into NAL units, groups those into access units (frames) and writes them length-prefixed, which is the form MP4 expects. Video timestamps are on a 90 kHz clock. Audio can be ADTS AAC, MP3, AC-3 or E-AC-3, including HLS "packed audio" (raw .aac files with an ID3 tag carrying the timestamp). fMP4 segments are parsed as moof + mdat pairs instead.
A two-hour 1080p stream is several gigabytes, so the samples can't live in memory. They go to a spool file as they arrive; only the sample tables (offset, size, timestamps) stay in RAM:
int append(Uint8List data) {
final off = length;
_raf.setPositionSync(length);
_raf.writeFromSync(data);
length += data.length;
return off; // where this sample lives in the spool
}
Synchronous I/O is fine here because we're already in a background isolate.
5. Writing an MP4 people can actually open
The output is a regular, non-fragmented MP4 with the moov box before mdat ("faststart"), so it opens instantly and plays while it's still being read. Since all sample tables are known by the end, chunk offsets are calculated before anything is written. Then the sample bytes are copied from the spool into the output file.
The bug that cost me the most time: downloaded files played fine inside the app, but the Android gallery showed no thumbnail and other apps refused to open them. HLS streams often use avc3/hev1 sample entries, which allow the parameter sets (SPS/PPS) to arrive in-band, inside the samples. Android's MediaExtractor and iOS's AVFoundation don't accept those entries. If the configuration box already contains the parameter sets, the fix is to write the compatible type instead:
const compatFourcc = {'avc3': 'avc1', 'hev1': 'hvc1', 'dvhe': 'dvh1'};
In-band parameter sets from quality switches stay in the samples; decoders handle those fine.
A few smaller details that matter for "it just plays":
- AAC encoder priming is written as an edit list, so audio doesn't start a few milliseconds late.
- Gaps in the audio are filled with silent frames, so audio and video stay in sync after a discontinuity.
- WebM is passed through untouched (there's no WebM parser), and a TS stream that can't be converted is saved as a plain
.tsinstead of failing.
Bonus: recording players that never expose a file
Some players feed the browser's MediaSource from JavaScript and never request a downloadable URL. For those, Vidkap captures the bytes the page appends to its SourceBuffers while the video replays from the start at high speed. The same demuxers re-parse those bytes, and the same assembler writes the MP4. The Chrome extension does the same thing at up to 16× speed.
What it deliberately doesn't do
- No DRM. Widevine, FairPlay and SAMPLE-AES are detected and refused.
- No YouTube. Google Play and Chrome Web Store policies don't allow it, so it's switched off there instead of half-working.
Takeaways
If you only need to remux, not transcode, you can get surprisingly far in pure Dart. Parsing the containers is mostly careful byte reading. The hard part is the long tail of real-world streams: bad padding, odd sample entries, Referer checks and inherited DASH attributes. Running each job in its own isolate kept all of that away from the UI.
If you want to see the result:
- Android app: Vidkap on Google Play
- Chrome extension: Vidkap on the Chrome Web Store
Both are free, with no ads, account or tracking. Happy to answer questions about the HLS/DASH or MP4 side in the comments.
Top comments (0)