Flutter Offline Audio: Cache and Play MP3s

Flutter Offline Audio: Cache and Play MP3s

# flutter# audio# justaudio# caching
Flutter Offline Audio: Cache and Play MP3sMuhammad Uzair

Offline audio is the difference between an app users trust and an app they abandon. When I built Punj...

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.

\`dart
import 'dart:io';

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

Future 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:

\`dart
import 'package:just_audio/just_audio.dart';

final player = AudioPlayer();

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

\`dart
Future 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.

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

Future 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:

\`dart
StreamBuilder(
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()));
  },
);
Enter fullscreen mode Exit fullscreen mode

},
);
`\

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.


Originally published at uzairxdev.online