DEV Community

Jitendra Saini
Jitendra Saini

Posted on

Building a Production Flutter Media Client: Riverpod, media_kit, and Offline Downloads

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.

I've been doing Android professionally for a while. On the side I built TubeNative — 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.

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.

Note: 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.


Why I didn't use a WebView

I know the shortcut: load the mobile site in a WebView and call it a day.

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.

That decision cascades into everything else.


Folder layout (boring but it saved me)

Nothing fancy — feature folders plus a core/ layer:

  • Features: home, search, player, channel, playlist, downloads, library, settings
  • Core: youtube (extraction + repo), storage (Hive), router, theme, errors

Presentation talks to Riverpod. Riverpod talks to repositories. Repositories talk to YoutubeService, HiveLocalStorage, and DownloadManager.

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 build() method.


GoRouter and the bottom nav problem

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.

I used StatefulShellRoute.indexedStack so Home, Search, Library, and Settings keep their state in an indexed stack. Player, channel, and playlist are outside the shell as full-screen routes.

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

Not rocket science. But getting back-stack + PiP + shell tabs to play nice together took longer than the feed UI.


Riverpod without codegen

I use Riverpod, but I skipped riverpod_annotation and build_runner.

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

Patterns in practice:

  • Provider for long-lived stuff (router, download manager, repo)
  • NotifierProvider for playback session state
  • ref.onDispose to close HTTP clients and cancel download subscriptions

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

Boring? Yes. Reliable? Also yes.


Getting streams (the part that never stays "done")

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.

The lesson that kept biting me:

Signed video URLs expire. Sometimes within hours.

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 StreamOption, 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.

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

YouTube changing things without notice is not a theoretical risk. It's Tuesday.


Playback: media_kit and the edge cases

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

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

The PlaybackController has flags like _suppressPositionUpdates 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.

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

I won't lie — this file is the largest in the project. Playback always is.


Downloads: byte-range resume (where I lost the most time)

Offline was the feature I wanted most, and the one that broke the most assumptions.

Goals:

  1. Pause / resume large files
  2. Survive process death
  3. Optionally copy finished videos to the gallery (if permission allows)

Flow:

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

On startup, reconcile incomplete jobs:

  • Partial file exists → mark paused, show "tap resume"
  • No bytes → failed with a clear message, not infinite spinner

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.

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.


Hive on device

Everything local goes through Hive boxes:

  • Settings (theme, default quality, background play toggles)
  • Watch history, favorites
  • Download entries (status, bytes received, paths)
  • Playback resume positions

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

AppSettings is a single keyed document in the settings box. Downloads and history are lists keyed by id. Nothing exotic.


Errors and logging

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

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.


What I'd tell past-me

  1. Spec out download resume on paper before coding — 416 responses, partial files, queue restart
  2. Don't persist stream URLs. Persist video ids and re-resolve.
  3. Shell route + player route separation early — retrofitting PiP later sucks
  4. Integration tests for DownloadManager sooner. Unit tests didn't catch filesystem timing.

If you want the full codebase

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.

Demo: https://www.youtube.com/watch?v=IYVsEIgzGwI

Source: https://payhip.com/b/lS4Xr ($99 one-time)

Comes with README, LICENSE, DISCLAIMER, tests, release checklist. Not open source — commercial license.

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.

— Jitendra

Top comments (0)