Media is the feature that makes a Flutter app feel either premium or broken. A course app, a fitness app, a news app, a security-camera companion — they all end up with the same list of defects in the same order: playback stutters on Android mid-range, the first frame takes four seconds on cellular, audio dies the moment the user locks the screen, iOS refuses Picture-in-Picture, and the licensing team asks about DRM three weeks before launch.
None of those are hard problems individually. They are hard because teams reach for video_player, ship it, and then discover that a playback widget is not a playback architecture. This tutorial builds the architecture: a player abstraction you can swap, HLS/DASH adaptive streaming, disk caching and offline downloads, background audio with lock-screen controls, Picture-in-Picture, DRM, and the metrics that tell you whether any of it works on real devices.
Versions below are Flutter 3.4x / Dart 3.x.
Pick the playback engine before you write UI
There are three realistic choices in 2026, and the decision is mostly about which native player you want underneath.
| Package | Native engine | Good at | Watch out for |
|---|---|---|---|
video_player | AVPlayer (iOS) / ExoPlayer–Media3 (Android) | Simple inline video, first-party support, smallest footprint | No PiP, no DRM, no caching, limited track selection |
media_kit | libmpv | Desktop + web parity, wide codec support, precise seeking | Larger binary; native libs per platform |
better_player_plus / ExoPlayer wrappers | Media3 / AVPlayer | HLS/DASH, subtitles, DRM, cache, PiP out of the box | Wrapper churn; verify maintenance before adopting |
For audio, the pairing that has held up for years is just_audio for playback plus audio_service for background and lock-screen integration. For live streams and conferencing, none of these apply — you want WebRTC via flutter_webrtc or a vendor SDK, which is a different article.
Whatever you pick, do not let the package type leak into your widgets. One thin interface keeps a swap from becoming a rewrite:
// lib/media/player.dart
abstract interface class MediaPlayer {
Future<void> load(MediaSource source, {Duration? startAt});
Future<void> play();
Future<void> pause();
Future<void> seek(Duration position);
Future<void> setTrack(TrackSelection selection);
Stream<PlaybackState> get state; // buffering, playing, paused, ended, error
Stream<Duration> get position;
Future<void> dispose();
}
class MediaSource {
const MediaSource({
required this.uri,
this.drm,
this.headers = const {},
this.offlineKey,
});
final Uri uri;
final DrmConfig? drm;
final Map<String, String> headers;
final String? offlineKey; // set when the asset is downloaded locally
}
Every screen depends on MediaPlayer. Swapping video_player for a Media3 wrapper when DRM lands becomes one file and a fake in tests.
Adaptive streaming, not MP4 files
Serving a single progressive MP4 is the number one cause of "the video buffers on my phone". A progressive file has one bitrate; a user on a degraded connection either waits or gets nothing. Adaptive streaming (HLS on iOS, HLS or DASH on Android) ships a manifest with several renditions and lets the player switch mid-playback.
Ask the backend team for:
- HLS with CMAF/fMP4 segments, 2–6 second segments — playable on both platforms from one packaging job.
- A rendition ladder that starts low: 360p at ~600 kbps as the bottom rung. The bottom rung is what determines startup time on a bad connection.
- A signed, short-lived URL policy your client can refresh (see below).
Startup latency is dominated by two things you control from the client: initial buffer size and whether you preload. A "tap to watch" list benefits enormously from warming the next item:
class PlaylistPrefetcher {
PlaylistPrefetcher(this._factory);
final MediaPlayer Function() _factory;
MediaPlayer? _warm;
String? _warmId;
/// Call when an item scrolls into view, not when it is tapped.
Future<void> warm(MediaItem item) async {
if (_warmId == item.id) return;
await _warm?.dispose();
_warm = _factory()..load(item.source); // buffers the first segments only
_warmId = item.id;
}
MediaPlayer take(MediaItem item) {
if (_warmId == item.id && _warm != null) {
final p = _warm!;
_warm = null;
_warmId = null;
return p; // near-instant start
}
return _factory()..load(item.source);
}
}
Warm exactly one item. Warming three burns the user's data plan and gets you a one-star review from someone on a metered plan.
Expiring URLs: refresh, don't crash
Signed CDN URLs expire. If a user pauses for forty minutes and resumes, the next segment request returns 403 and the player surfaces an opaque error. Handle it as a first-class case: catch the playback error, re-mint the URL, reload at the current position.
Future<void> _onPlaybackError(PlaybackError e) async {
if (e.httpStatus == 403 || e.httpStatus == 401) {
final at = await player.positionNow();
final fresh = await api.signedUrlFor(item.id);
await player.load(item.source.copyWith(uri: fresh), startAt: at);
await player.play();
return; // invisible to the user
}
_showRetry(e); // everything else gets a real error UI
}
Keep a retry counter. Two silent re-signs, then show the error — otherwise a genuinely revoked asset becomes an infinite loop.
Caching and offline downloads
Two different features that teams constantly conflate.
Cache is opportunistic: segments already fetched are kept on disk so scrubbing backwards and re-watching are free. Wire it at the HTTP layer with a bounded LRU cache (flutter_cache_manager or the native player's own cache). Bound it — 512 MB is a reasonable default — and clear it on logout.
Download is explicit: the user taps "Save offline", you fetch every segment of one rendition plus subtitles, store it in the app's documents directory, and record it in a database. That needs real download management.
class OfflineAsset {
final String id;
final String title;
final String localPath; // directory containing the local manifest
final int bytes;
final DateTime downloadedAt;
final DateTime? licenseExpiresAt; // DRM offline licences expire separately
final DownloadStatus status; // queued, running, paused, done, failed
}
Three rules that prevent the usual support tickets:
- Download on Wi-Fi by default, with an explicit "allow cellular" toggle. Use
connectivity_plusto pause automatically when Wi-Fi drops. - Store under the app's documents directory and mark it excluded from iCloud backup (
NSURLIsExcludedFromBackupKey), or Apple will reject your app for backing up gigabytes of re-downloadable content. - Resume, don't restart. Segment-level downloads resume naturally if you record which segments landed; a single-file download needs HTTP range requests.
Play offline by pointing the same MediaSource at the local manifest, with offlineKey set so the DRM layer knows to use the stored licence rather than requesting a new one.
Background audio and lock-screen controls
For anything audio-led — podcasts, courses, meditation — playback must continue when the app is backgrounded, and the lock screen must show real controls. This is platform configuration first, Dart second.
iOS (ios/Runner/Info.plist):
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
and set the audio session category to playback (not ambient, which stops on lock).
Android: a foreground service with a media notification, declared in AndroidManifest.xml:
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<service
android:name="com.ryanheise.audioservice.AudioService"
android:foregroundServiceType="mediaPlayback"
android:exported="true">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
foregroundServiceType="mediaPlayback" and the matching runtime permission are mandatory on Android 14+; miss them and the service throws at start on modern devices while working fine on your older test phone.
Then the Dart side:
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final handler = await AudioService.init(
builder: () => AudioPlayerHandler(),
config: const AudioServiceConfig(
androidNotificationChannelId: 'com.aviaryapps.audio',
androidNotificationChannelName: 'Playback',
androidNotificationOngoing: true,
androidStopForegroundOnPause: true,
),
);
runApp(App(handler: handler));
}
class AudioPlayerHandler extends BaseAudioHandler with SeekHandler {
final _player = AudioPlayer();
AudioPlayerHandler() {
_player.playbackEventStream.map(_toState).pipe(playbackState);
}
@override Future<void> play() => _player.play();
@override Future<void> pause() => _player.pause();
@override Future<void> seek(Duration p) => _player.seek(p);
@override Future<void> stop() async { await _player.stop(); await super.stop(); }
PlaybackState _toState(PlaybackEvent e) => PlaybackState(
controls: [MediaControl.rewind, if (_player.playing) MediaControl.pause else MediaControl.play, MediaControl.fastForward],
systemActions: const {MediaAction.seek},
processingState: switch (e.processingState) {
ProcessingState.idle => AudioProcessingState.idle,
ProcessingState.loading => AudioProcessingState.loading,
ProcessingState.buffering => AudioProcessingState.buffering,
ProcessingState.ready => AudioProcessingState.ready,
ProcessingState.completed => AudioProcessingState.completed,
},
playing: _player.playing,
updatePosition: _player.position,
bufferedPosition: _player.bufferedPosition,
speed: _player.speed,
);
}
Test the awkward interruptions, because users hit them daily: an incoming call, a car Bluetooth connect/disconnect, another app taking audio focus, headphones unplugged (must pause — both stores treat "keeps blasting from the speaker" as a bug), and Android's Doze after an hour of screen-off playback.
Picture-in-Picture
PiP is not available through video_player, and it is platform-native on both sides:
- Android: declare
android:supportsPictureInPicture="true"andandroid:resizeableActivity="true"on the activity, then callenterPictureInPictureModewith an aspect ratio when the user backgrounds during playback. Flutter renders into the same surface, so the video keeps playing — but your Flutter UI chrome shrinks with it, which looks wrong. Hide controls whenWidgetsBindingObserverreports the PiP transition. - iOS: requires
AVPictureInPictureControllerattached to theAVPlayerLayer, which means a plugin that exposes the underlying layer. Avideo_playertexture will not give you one.
If PiP is a hard requirement, choose the playback package for it on day one. Retrofitting PiP is one of the more expensive late changes in a media app.
DRM, briefly and honestly
If you carry licensed content, you will need Widevine on Android and FairPlay on iOS. The client work is small; the integration work is not.
class DrmConfig {
const DrmConfig({required this.scheme, required this.licenseUri, this.headers = const {}, this.fairplayCertUri});
final DrmScheme scheme; // widevine | fairplay
final Uri licenseUri;
final Map<String, String> headers; // auth token for the licence request
final Uri? fairplayCertUri; // iOS only
}
Three things to plan for:
- Two packaging outputs. FairPlay requires HLS; Widevine is usually DASH (Android will take HLS+Widevine, but check your packager). Your CDN pipeline produces both.
- Licence proxying. The licence request carries a user entitlement token; route it through your API so revocation is yours, not the DRM vendor's.
- Offline licences expire independently of the download. Store
licenseExpiresAt, renew in the background, and show "expires in 3 days" in the UI. Otherwise a user on a plane finds a downloaded film that refuses to play.
Also test on an emulator and hardware: emulators generally only support Widevine L3, and some providers require L1 for HD, so an emulator-only test proves nothing about the release build.
Measure playback quality, not just crashes
Crash-free rate says nothing about whether video works. Log four events per session and you can argue about quality with data:
| Metric | Definition | Target to aim at |
|---|---|---|
| Startup time | tap → first frame rendered | p75 under 1.5 s on 4G |
| Rebuffer ratio | buffering time ÷ watch time | under 0.5% |
| Playback failure rate | sessions ending in error ÷ sessions | under 0.5% |
| Average bitrate | delivered bitrate, weighted by time | ladder-dependent |
player.state.listen((s) {
switch (s) {
case PlaybackState.playing when _firstFrame == null:
_firstFrame = _clock.elapsed;
analytics.log('video_start', {'ms': _firstFrame!.inMilliseconds, 'asset': item.id});
case PlaybackState.buffering:
_rebufferStart = _clock.elapsed;
case PlaybackState.error(:final code):
analytics.log('video_error', {'code': code, 'position_ms': _lastPosition.inMilliseconds});
default:
break;
}
});
Segment those by network type and device tier. Nearly every "video is slow" complaint we have investigated turned out to be one rendition ladder mistake or one device class with a software-decode fallback.
Pre-launch checklist
- Playback survives lock, call interruption, and headphone unplug.
- Lock-screen and Bluetooth controls show correct title, artwork, and position.
- Expiring URLs re-sign silently, bounded to two attempts.
- Cache is bounded and cleared on logout; downloads excluded from backup.
- Downloads pause on cellular unless explicitly allowed, and resume after a crash.
- Subtitles and audio-track switching work, and captions honour the OS text-size setting.
- PiP behaves on both platforms, with app chrome hidden.
- DRM verified on physical devices, plus one offline-licence-expiry test.
- Startup time and rebuffer ratio dashboards exist before launch, not after.
Media features look like a two-week widget task and behave like a two-month subsystem. The difference between the two estimates is almost entirely the list above. Get the player interface, the streaming format, and the background/PiP requirements settled in week one, and the rest is steady work.
If you are scoping a media-heavy Flutter build — or repairing one that buffers — get in touch. Our Flutter consultants have shipped streaming, offline download, and DRM-protected playback on both platforms.