<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: FCS Apps</title>
    <description>The latest articles on DEV Community by FCS Apps (@fcsapps).</description>
    <link>https://dev.to/fcsapps</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4218045%2F5ce25bc2-5910-4b5e-b764-405bb57f2908.png</url>
      <title>DEV Community: FCS Apps</title>
      <link>https://dev.to/fcsapps</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/fcsapps"/>
    <language>en</language>
    <item>
      <title>Building a video downloader app in Flutter without ffmpeg</title>
      <dc:creator>FCS Apps</dc:creator>
      <pubDate>Fri, 30 Oct 2026 23:22:01 +0000</pubDate>
      <link>https://dev.to/fcsapps/building-a-video-downloader-app-in-flutter-without-ffmpeg-4i6j</link>
      <guid>https://dev.to/fcsapps/building-a-video-downloader-app-in-flutter-without-ffmpeg-4i6j</guid>
      <description>&lt;p&gt;I build Vidkap, a &lt;strong&gt;video downloader app&lt;/strong&gt; for Android and a &lt;strong&gt;video downloader extension&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The usual Flutter answer is to bundle ffmpeg. I didn't, for three reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It adds tens of megabytes to the app.&lt;/li&gt;
&lt;li&gt;FFmpegKit, the common Flutter wrapper, was retired in 2025.&lt;/li&gt;
&lt;li&gt;I wanted full control over progress, cancellation and resuming.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;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
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Parsing the playlists
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;HLS&lt;/strong&gt; 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, &lt;code&gt;EXT-X-MAP&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DASH&lt;/strong&gt; is XML with three ways to describe segments: &lt;code&gt;SegmentTemplate&lt;/code&gt; (with or without &lt;code&gt;SegmentTimeline&lt;/code&gt;), &lt;code&gt;SegmentList&lt;/code&gt; and &lt;code&gt;SegmentBase&lt;/code&gt;. The last one means downloading the &lt;code&gt;sidx&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Both parsers check for DRM and stop there. Widevine, FairPlay and SAMPLE-AES content is detected and refused by design.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Downloading in parallel, feeding in order
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Start another download only while we're under the concurrency limit and&lt;/span&gt;
&lt;span class="c1"&gt;// downloaded-but-not-yet-consumed segments stay within the window.&lt;/span&gt;
&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isNotEmpty&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="n"&gt;inflight&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;job&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;concurrency&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="n"&gt;inflight&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;buffered&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="c1"&gt;// window = concurrency * 2&lt;/span&gt;
  &lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;removeAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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 (&lt;code&gt;&amp;lt;meta name="referrer" content="no-referrer"&amp;gt;&lt;/code&gt;). The fix: if a job gets a 403 before a single media byte arrives, it's retried once without the Referer.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. AES-128
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="c1"&gt;// A padded TS segment can never have an encrypted length that's a multiple&lt;/span&gt;
&lt;span class="c1"&gt;// of 188 bytes. If it is, the "padding" is a coincidence: keep every byte.&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;length&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;188&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mh"&gt;0x47&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Uint8List&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;sublistView&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;pad&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keys are fetched once per URL and cached, so a playlist with 800 segments and one key makes one key request.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Demuxing MPEG-TS without running out of memory
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;.aac&lt;/code&gt; files with an ID3 tag carrying the timestamp). fMP4 segments are parsed as &lt;code&gt;moof&lt;/code&gt; + &lt;code&gt;mdat&lt;/code&gt; pairs instead.&lt;/p&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kt"&gt;int&lt;/span&gt; &lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Uint8List&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;final&lt;/span&gt; &lt;span class="n"&gt;off&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;_raf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setPositionSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="n"&gt;_raf&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;writeFromSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="n"&gt;length&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// where this sample lives in the spool&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Synchronous I/O is fine here because we're already in a background isolate.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Writing an MP4 people can actually open
&lt;/h2&gt;

&lt;p&gt;The output is a regular, non-fragmented MP4 with the &lt;code&gt;moov&lt;/code&gt; box before &lt;code&gt;mdat&lt;/code&gt; ("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.&lt;/p&gt;

&lt;p&gt;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 &lt;code&gt;avc3&lt;/code&gt;/&lt;code&gt;hev1&lt;/code&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight dart"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;compatFourcc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;'avc3'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;'avc1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'hev1'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;'hvc1'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;'dvhe'&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;'dvh1'&lt;/span&gt;&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In-band parameter sets from quality switches stay in the samples; decoders handle those fine.&lt;/p&gt;

&lt;p&gt;A few smaller details that matter for "it just plays":&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AAC encoder priming is written as an edit list, so audio doesn't start a few milliseconds late.&lt;/li&gt;
&lt;li&gt;Gaps in the audio are filled with silent frames, so audio and video stay in sync after a discontinuity.&lt;/li&gt;
&lt;li&gt;WebM is passed through untouched (there's no WebM parser), and a TS stream that can't be converted is saved as a plain &lt;code&gt;.ts&lt;/code&gt; instead of failing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Bonus: recording players that never expose a file
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it deliberately doesn't do
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No DRM.&lt;/strong&gt; Widevine, FairPlay and SAMPLE-AES are detected and refused.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No YouTube.&lt;/strong&gt; Google Play and Chrome Web Store policies don't allow it, so it's switched off there instead of half-working.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;If you want to see the result:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Android app: &lt;a href="https://play.google.com/store/apps/details?id=com.vidkap.app" rel="noopener noreferrer"&gt;Vidkap on Google Play&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Chrome extension: &lt;a href="https://chromewebstore.google.com/detail/vidkap-%E2%80%93-video-downloader/acpggpmcffcpfnnndiopbmloenpbbkad" rel="noopener noreferrer"&gt;Vidkap on the Chrome Web Store&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both are free, with no ads, account or tracking. Happy to answer questions about the HLS/DASH or MP4 side in the comments.&lt;/p&gt;

</description>
      <category>flutter</category>
      <category>dart</category>
      <category>android</category>
      <category>video</category>
    </item>
  </channel>
</rss>
