Skip to main content

Mobile Development6 min read

Flutter Offline Audio: Cache and Play MP3s

Learn offline audio playback in Flutter: cache MP3s locally, play them with just_audio, cap cache size, and handle weak networks gracefully.

  • #flutter
  • #audio
  • #just-audio
  • #caching
  • #mobile-development
Flutter Offline Audio: Cache and Play MP3s

Offline audio is the difference between an app users trust and an app they abandon. When I built Punj Surah — my Quran companion app with high-quality MP3 audio recitation, a page-by-page reading view, Qibla Finder, Salah tracker, and Tasbeeh counter — one requirement was non-negotiable: once a recitation is downloaded, it must play anywhere. In a mosque basement, on a flight, in a village with one bar of signal. That offline guarantee helped the app reach 10,000+ downloads while holding a 99.9% crash-free rate.

This guide walks through the exact pattern I use in production: download MP3s into the app's documents directory, play them from local files with just_audio, cap the cache so it never eats the user's storage, and degrade gracefully when the network dies mid-download. Every snippet below is written for the current stable Flutter and Dart.

Cache first, stream second

The architecture is three layers. A download manager fetches each file exactly once and stores it in the app's documents directory. A cache layer applies a least-recently-used eviction policy that deletes the least-recently-played files when the cache grows past a limit you set. A player layer always plays local files and only touches the network when a file is missing.

Keep a small manifest — a JSON file or a few key-value entries — mapping each track ID to its filename and a flag for whether it is fully downloaded. Never play a partially downloaded file: if the app is killed mid-download, the manifest will tell you the file is incomplete and it must be fetched again.

Step 1: Download MP3s with progress

Download to a temporary .part file first, then rename it when the download completes. That way a crash or a killed app can never leave a half-written file that the player mistakes for a complete one.

import 'dart:io';

import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';

Future<File> downloadTrack(
  String url,
  String fileName, {
  void Function(double progress)? onProgress,
}) async {
  final dir = await getApplicationDocumentsDirectory();
  final file = File('${dir.path}/audio/$fileName');

  if (await file.exists()) return file; // Already cached.

  final tmp = File('${file.path}.part');
  final request = http.Request('GET', Uri.parse(url));
  final response = await http.Client().send(request);

  final total = response.contentLength ?? 0;
  final sink = tmp.openWrite();
  int received = 0;

  try {
    await for (final chunk in response.stream) {
      sink.add(chunk);
      received += chunk.length;
      if (total > 0) onProgress?.call(received / total);
    }
    await sink.flush();
    await sink.close();
    return await tmp.rename(file.path); // Atomic: complete file only.
  } catch (_) {
    await sink.close();
    if (await tmp.exists()) await tmp.delete();
    rethrow;
  }
}

Two details matter here. First, contentLength can be absent for chunked responses, so guard the progress math. Second, always delete the .part file on failure — stale partial files are the classic cause of a player that "plays" silence.

Step 2: Play from the local cache

just_audio can play a local file path directly, which gives you instant start, seeking, and zero buffering:

import 'package:just_audio/just_audio.dart';

final player = AudioPlayer();

Future<void> playCachedTrack(File file) async {
  await player.stop();
  await player.setFilePath(file.path);
  await player.play();
}

@override
void dispose() {
  player.dispose();
  super.dispose();
}

Always dispose the player. An AudioPlayer holds native audio resources, and leaking one per screen visit is a slow memory drain that the profiler makes obvious but users notice first.

Step 3: Fall back to streaming when a file is not cached

The caller decides the strategy: try the cache, and if the file is missing, download it in the background and play it; if the download itself fails with a SocketException, stream the URL directly as a last resort.

Future<void> playTrack(String trackId, String url, String fileName) async {
  final file = File('${(await getApplicationDocumentsDirectory()).path}/audio/$fileName');

  if (await file.exists()) {
    await playCachedTrack(file);
    return;
  }

  try {
    final downloaded = await downloadTrack(url, fileName);
    await playCachedTrack(downloaded);
  } on SocketException {
    // No network at all: stream it instead, it may still buffer.
    await player.setUrl(url);
    await player.play();
  }
}

This is the graceful-degradation ladder that keeps an audio app usable on terrible networks: cached file, then fresh download, then raw streaming.

Step 4: Keep the cache under a storage budget

Cached audio accumulates fast — a single Surah recitation can be 15 MB. Pick a budget (say 500 MB), and evict the least-recently-played files whenever you cross it. Run the trim after every completed download.

class _CacheEntry {
  final File file;
  final int size;
  final DateTime modified;
  _CacheEntry({required this.file, required this.size, required this.modified});
}

Future<void> trimCache(Directory audioDir, {required int maxBytes}) async {
  final entries = <_CacheEntry>[];
  int total = 0;

  await for (final entity in audioDir.list()) {
    if (entity is! File || entity.path.endsWith('.part')) continue;
    final stat = await entity.stat();
    entries.add(_CacheEntry(file: entity, size: stat.size, modified: stat.modified));
    total += stat.size;
  }

  if (total <= maxBytes) return;

  entries.sort((a, b) => a.modified.compareTo(b.modified)); // Oldest first.
  for (final entry in entries) {
    await entry.file.delete();
    total -= entry.size;
    if (total <= maxBytes) break;
  }
}

Note the two guards: .part files are skipped so an in-progress download never gets evicted mid-write, and the currently playing file keeps working because just_audio already has it open — deleting the file handle path on Android does not kill the active playback session.

Step 5: A seek bar that feels instant

With local files, seeking is nearly free. Wire the player's streams into a slider so the UI stays in sync:

StreamBuilder<Duration>(
  stream: player.positionStream,
  builder: (context, snapshot) {
    final position = snapshot.data ?? Duration.zero;
    final duration = player.duration ?? Duration.zero;
    final max = duration.inMilliseconds.toDouble().clamp(1.0, double.infinity);

    return Slider(
      value: position.inMilliseconds.toDouble().clamp(0.0, max),
      max: max,
      onChanged: (value) {
        player.seek(Duration(milliseconds: value.round()));
      },
    );
  },
);

Clamping the value protects against the brief moment when position arrives before duration — without it, the slider throws a range error on the very first frame.

Checklist before you ship

  • Set the audio session. Use the audio_session package to configure the platform session (for example, ducking on Android and the silent-switch behavior on iOS). Players that skip this get one-star reviews from users whose recitation stops when a notification arrives.
  • Background playback needs manifest entries. On iOS, add audio to UIBackgroundModes in Info.plist; on Android, use a foreground service with the mediaPlayback type for Android 14+. Without them, playback stops the moment the screen locks.
  • Do not request media permissions you do not need. Reading the user's own music library on Android 13+ needs READ_MEDIA_AUDIO, but files your app downloaded into its own documents directory need no permission at all.
  • Test the kill-during-download case. Force-quit the app mid-download, reopen it, and confirm the track re-downloads instead of playing silence. Your manifest flag is what makes this work.
  • Profile on a low-end device. Local playback is cheap, but a 20 MB file decoded while the UI also renders a waveform animation can still jank on older phones. Check it with the DevTools performance view.

Offline audio is one of those features users never praise and always notice when it is missing. Build the cache layer once, and every audio app you ship afterward — recitations, podcasts, language lessons — gets it for free.

If you are building an audio feature and want it done right the first time, get in touch and let's talk about your project.

Frequently asked questions

How do I play a downloaded MP3 in Flutter?
Use just_audio and call setFilePath with the local file path, then play. Local playback gives instant start, seeking, and zero buffering. Always dispose the player to avoid leaking native audio resources.
What happens if the app is killed during a download?
Keep a manifest flag marking each track as fully downloaded. On reopen, an incomplete file is re-downloaded instead of played, and downloading to a .part file first means a crash can never leave a half-written file with the real name.
How do I keep cached audio from filling the user's storage?
Set a storage budget, such as 500 MB, and trim the cache with least-recently-played eviction after every completed download. Skip .part files so an in-progress download is never evicted mid-write.
Do I need media permissions to play downloaded audio files?
No. Files your app downloaded into its own documents directory need no permission. The READ_MEDIA_AUDIO permission is only needed to read the user's own music library on Android 13+.

Written by Muhammad Uzair

Flutter Developer & Mobile Engineering Team Lead.

Work with me
All articles
  • Mobile Development

    7 min read

    Build a Health Vitals Tracker in Flutter

    Build a Flutter health vitals tracker: sub-10-second one-hand logging, per-member family profiles, offline-first storage, trend charts and daily reminders.

  • Mobile Development

    8 min read

    Build a Qibla Finder in Flutter

    Build a Qibla finder in Flutter: compute the Kaaba bearing from GPS, read the compass sensor, render a smooth CustomPainter dial, and handle edge cases.

  • Mobile Development

    7 min read

    Control Android Ringer Mode in Flutter

    Control Android ringer mode from Flutter with a MethodChannel and AudioManager: silent, vibrate, and normal profiles plus the Do Not Disturb access step.