<?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: Jitendra Saini</title>
    <description>The latest articles on DEV Community by Jitendra Saini (@jitendrasaini).</description>
    <link>https://dev.to/jitendrasaini</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%2F4118930%2F85ceeb49-0d74-47e5-96a3-92b328e2b5ae.jpg</url>
      <title>DEV Community: Jitendra Saini</title>
      <link>https://dev.to/jitendrasaini</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jitendrasaini"/>
    <language>en</language>
    <item>
      <title>Building a Production Flutter Media Client: Riverpod, media_kit, and Offline Downloads</title>
      <dc:creator>Jitendra Saini</dc:creator>
      <pubDate>Thu, 10 Sep 2026 12:42:31 +0000</pubDate>
      <link>https://dev.to/jitendrasaini/building-a-production-flutter-media-client-riverpod-mediakit-and-offline-downloads-3i7c</link>
      <guid>https://dev.to/jitendrasaini/building-a-production-flutter-media-client-riverpod-mediakit-and-offline-downloads-3i7c</guid>
      <description>&lt;p&gt;Most Flutter tutorials stop at a counter app or a Firebase login screen. That's fine for learning widgets, but it doesn't teach you how to ship something with a real player, real network edge cases, and data that survives when the user kills the app from recents.&lt;/p&gt;

&lt;p&gt;I've been doing Android professionally for a while. On the side I built &lt;strong&gt;TubeNative&lt;/strong&gt; — a native Flutter media client for Android (sideload only, not Play Store). This post is about the parts that actually took time: navigation shell, stream extraction, playback, and offline downloads with resume.&lt;/p&gt;

&lt;p&gt;I'm not going to pretend this is a generic tutorial you can copy in an afternoon. Some of this took weeks. But if you're thinking about building a media app in Flutter, maybe you'll avoid a few of the traps I walked into.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; TubeNative isn't affiliated with YouTube or Google. This is an engineering write-up about Flutter architecture — not a guide to breaking platform rules. If you ship something derived from this kind of app, compliance is on you.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Why I didn't use a WebView
&lt;/h2&gt;

&lt;p&gt;I know the shortcut: load the mobile site in a WebView and call it a day.&lt;/p&gt;

&lt;p&gt;I tried that mentally, rejected it quickly. You lose native gestures (brightness/volume on drag), PiP is a fight, background audio is a bigger fight, and downloads basically don't exist in a clean way. For something I wanted to feel like an app — not a browser tab — I needed extracted stream URLs and a native player.&lt;/p&gt;

&lt;p&gt;That decision cascades into everything else.&lt;/p&gt;




&lt;h2&gt;
  
  
  Folder layout (boring but it saved me)
&lt;/h2&gt;

&lt;p&gt;Nothing fancy — feature folders plus a &lt;code&gt;core/&lt;/code&gt; layer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Features:&lt;/strong&gt; home, search, player, channel, playlist, downloads, library, settings&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Core:&lt;/strong&gt; youtube (extraction + repo), storage (Hive), router, theme, errors&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Presentation talks to Riverpod. Riverpod talks to repositories. Repositories talk to &lt;code&gt;YoutubeService&lt;/code&gt;, &lt;code&gt;HiveLocalStorage&lt;/code&gt;, and &lt;code&gt;DownloadManager&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The rule I stuck to: UI widgets don't call HTTP. Ever. Sounds obvious until it's 2am and you're tempted to "just fetch this one list" inside a &lt;code&gt;build()&lt;/code&gt; method.&lt;/p&gt;




&lt;h2&gt;
  
  
  GoRouter and the bottom nav problem
&lt;/h2&gt;

&lt;p&gt;If you've built a bottom-nav app in Flutter, you've probably hit this: user scrolls home feed, switches to Library, comes back — and Home is back at the top. Annoying.&lt;/p&gt;

&lt;p&gt;I used &lt;code&gt;StatefulShellRoute.indexedStack&lt;/code&gt; so Home, Search, Library, and Settings keep their state in an indexed stack. Player, channel, and playlist are &lt;strong&gt;outside&lt;/strong&gt; the shell as full-screen routes.&lt;/p&gt;

&lt;p&gt;One small thing that mattered more than I expected: a &lt;code&gt;RouteObserver&lt;/code&gt; so the player page knows when another route covers it. That hooks into PiP behavior when the user navigates away mid-video.&lt;/p&gt;

&lt;p&gt;Not rocket science. But getting back-stack + PiP + shell tabs to play nice together took longer than the feed UI.&lt;/p&gt;




&lt;h2&gt;
  
  
  Riverpod without codegen
&lt;/h2&gt;

&lt;p&gt;I use Riverpod, but I skipped &lt;code&gt;riverpod_annotation&lt;/code&gt; and &lt;code&gt;build_runner&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Reason: on Dart 3.11.4, running codegen in this repo was a mess — analyzer crashes, &lt;code&gt;.g.dart&lt;/code&gt; files getting wiped. Maybe that's fixed elsewhere; for this project I went hand-written providers and slept better.&lt;/p&gt;

&lt;p&gt;Patterns in practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Provider&lt;/code&gt; for long-lived stuff (router, download manager, repo)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;NotifierProvider&lt;/code&gt; for playback session state&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ref.onDispose&lt;/code&gt; to close HTTP clients and cancel download subscriptions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Example: &lt;code&gt;DownloadManager&lt;/code&gt; is provided once, and on first access it runs &lt;code&gt;reconcileOnStartup()&lt;/code&gt;. If the OS killed the app during a download, partial files on disk get marked &lt;strong&gt;paused&lt;/strong&gt; (user can resume) instead of showing a fake "downloading…" spinner forever.&lt;/p&gt;

&lt;p&gt;Boring? Yes. Reliable? Also yes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Getting streams (the part that never stays "done")
&lt;/h2&gt;

&lt;p&gt;The data layer wraps extraction logic — Innertube-style HTTP plus stream manifest resolution. Feeds, search with filters, channels, playlists — all funnel through a repository interface so the UI doesn't care which API shape came back.&lt;/p&gt;

&lt;p&gt;The lesson that kept biting me:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Signed video URLs expire.&lt;/strong&gt; Sometimes within hours.&lt;/p&gt;

&lt;p&gt;So for downloads, I don't store "the URL I'll fetch tomorrow." When a job actually runs, I re-resolve the manifest, pick a &lt;code&gt;StreamOption&lt;/code&gt;, then start bytes. If the user queued five videos and opens the app the next day, the queue still works because URLs are fresh at execution time.&lt;/p&gt;

&lt;p&gt;Pagination cursors from the extractor are opaque in-memory keys — not something you serialize to JSON. That confused me early when I thought I could persist search pages to disk. You can't, not cleanly. The service holds the cursor; the UI just asks for "next page."&lt;/p&gt;

&lt;p&gt;YouTube changing things without notice is not a theoretical risk. It's Tuesday.&lt;/p&gt;




&lt;h2&gt;
  
  
  Playback: media_kit and the edge cases
&lt;/h2&gt;

&lt;p&gt;Player stack is &lt;code&gt;media_kit&lt;/code&gt; (libmpv on Android) with a custom controls layer: quality, speed, related list, gestures for seek / brightness / volume.&lt;/p&gt;

&lt;p&gt;Background audio goes through &lt;code&gt;audio_service&lt;/code&gt; with a custom handler. There's also paths involving &lt;code&gt;just_audio&lt;/code&gt; when the manifest splits video and audio — DASH-style stuff where you can't just throw one URL at the player and hope.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;PlaybackController&lt;/code&gt; has flags like &lt;code&gt;_suppressPositionUpdates&lt;/code&gt; during quality switches (otherwise the slider jumps to zero when you didn't seek) and logic to ignore position stream updates while the user is dragging the seek bar. Small details. Users absolutely notice when those are wrong.&lt;/p&gt;

&lt;p&gt;PiP is delegated to a small platform service. Wakelock during playback. Resume position stored per video in Hive if the user enables "remember position."&lt;/p&gt;

&lt;p&gt;I won't lie — this file is the largest in the project. Playback always is.&lt;/p&gt;




&lt;h2&gt;
  
  
  Downloads: byte-range resume (where I lost the most time)
&lt;/h2&gt;

&lt;p&gt;Offline was the feature I wanted most, and the one that broke the most assumptions.&lt;/p&gt;

&lt;p&gt;Goals:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pause / resume large files&lt;/li&gt;
&lt;li&gt;Survive process death&lt;/li&gt;
&lt;li&gt;Optionally copy finished &lt;strong&gt;videos&lt;/strong&gt; to the gallery (if permission allows)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Flow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Bytes land in &lt;strong&gt;app-private storage&lt;/strong&gt; first (you need this for resume)&lt;/li&gt;
&lt;li&gt;HTTP &lt;code&gt;Range: bytes=N-&lt;/code&gt; where &lt;code&gt;N&lt;/code&gt; is what's already on disk&lt;/li&gt;
&lt;li&gt;Progress written back to Hive after chunks&lt;/li&gt;
&lt;li&gt;One active download at a time — sequential queue. Parallel downloads on mobile networks sounded cool until they didn't.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On startup, reconcile incomplete jobs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Partial file exists → mark paused, show "tap resume"&lt;/li&gt;
&lt;li&gt;No bytes → failed with a clear message, not infinite spinner&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When a video completes, there's a gallery publish step. If that fails (permission, OEM weirdness), the file still lives in private storage — download succeeded, gallery just didn't get a copy. I preferred that over failing the whole job.&lt;/p&gt;

&lt;p&gt;The bug that ate a week: treating a saved stream URL like it would work after the app restarted eight hours later. It won't. Re-resolve at job time. Obvious in hindsight.&lt;/p&gt;




&lt;h2&gt;
  
  
  Hive on device
&lt;/h2&gt;

&lt;p&gt;Everything local goes through Hive boxes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Settings (theme, default quality, background play toggles)&lt;/li&gt;
&lt;li&gt;Watch history, favorites&lt;/li&gt;
&lt;li&gt;Download entries (status, bytes received, paths)&lt;/li&gt;
&lt;li&gt;Playback resume positions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Adapters are &lt;strong&gt;hand-written&lt;/strong&gt;. README in the repo literally says don't run &lt;code&gt;build_runner&lt;/code&gt; — Hive codegen was part of the analyzer crash story. For six boxes and stable models, manual adapters were fine.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AppSettings&lt;/code&gt; is a single keyed document in the settings box. Downloads and history are lists keyed by id. Nothing exotic.&lt;/p&gt;




&lt;h2&gt;
  
  
  Errors and logging
&lt;/h2&gt;

&lt;p&gt;There's a small &lt;code&gt;Result&lt;/code&gt; type mapping exceptions to UI-facing failures. Empty search, timeout, extraction failure — each gets an intentional empty/error widget instead of a red screen.&lt;/p&gt;

&lt;p&gt;Errors also append to a rotating local log file on device. Not uploaded anywhere. I didn't want crash analytics phoning home for an app that's already sensitive topic-wise.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd tell past-me
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Spec out download resume &lt;strong&gt;on paper&lt;/strong&gt; before coding — 416 responses, partial files, queue restart&lt;/li&gt;
&lt;li&gt;Don't persist stream URLs. Persist video ids and re-resolve.&lt;/li&gt;
&lt;li&gt;Shell route + player route separation early — retrofitting PiP later sucks&lt;/li&gt;
&lt;li&gt;Integration tests for &lt;code&gt;DownloadManager&lt;/code&gt; sooner. Unit tests didn't catch filesystem timing.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  If you want the full codebase
&lt;/h2&gt;

&lt;p&gt;I sell the complete Android source (Flutter 3.11+, sideload only) for devs who'd rather extend a working app than spend months on player + download plumbing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Demo:&lt;/strong&gt; &lt;a href="https://www.youtube.com/watch?v=IYVsEIgzGwI" rel="noopener noreferrer"&gt;https://www.youtube.com/watch?v=IYVsEIgzGwI&lt;/a&gt;&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Source:&lt;/strong&gt; &lt;a href="https://payhip.com/b/lS4Xr" rel="noopener noreferrer"&gt;https://payhip.com/b/lS4Xr&lt;/a&gt; ($99 one-time)&lt;/p&gt;

&lt;p&gt;Comes with README, LICENSE, DISCLAIMER, tests, release checklist. Not open source — commercial license.&lt;/p&gt;

&lt;p&gt;If you've built something similar, I'd genuinely like to hear how you handled DASH audio/video merge or download resume in the comments. Still learning.&lt;/p&gt;

&lt;p&gt;— Jitendra&lt;/p&gt;

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