Tutorial · 15 min · intermediate
Native video playback
AVPlayer and ExoPlayer through one Dart controller — ready-made controls, instant-start caching, and a custom UI driven by the same listener.
What you'll build — running on device.
Video is the classic “just use the platform” feature: iOS ships AVPlayer,
Android ships ExoPlayer (Media3), and both are better at codecs,
buffering, and battery than anything an app embeds.
dartnative_video_player runs those real engines with one Dart
controller over FFI. You’ll build a player screen twice over: first with
the ready-made VideoPlayerWithControls widget, then a YouTube-style
control layer from scratch on the raw VideoPlayer — the DartNative
playground’s two video demos, carried into the tutorial byte-for-byte.
What you need
- A project from Your first DartNative app
dartnative_video_player: ^1.0.0in your pubspec (the regenerated registrant loads its symbols — just keepDartNativePluginRegistrant.registerAll()first inmain())
Step 1 — A controller and a player
The design splits in two, and everything else in this tutorial follows
from the split: a VideoPlayerController owns the native engine —
loading, playback state, position — while a widget merely shows its
pixels. Create the controller once, in initState:
_controller = VideoPlayerController(
dataSource: VideoDataSource.network(
_videoUrl,
cacheConfig: const VideoCacheConfig(useCache: false),
),
autoPlay: true,
autoDispose: false,
);
_controller.initialize();
Then hand it to the ready-made widget:
VideoPlayerWithControls(
controller: _controller,
controlsMode: VideoControlsMode.bottomBar,
onExitFullScreen: _forcePortrait,
)
That’s a shipping video player: a real AVPlayerLayer / ExoPlayer
PlayerView in your hierarchy, with controls — play/pause, ±10 s, a
scrubber, fullscreen — that auto-hide during playback.
VideoControlsMode.bottomBar anchors the controls to the screen bottom,
which is right for a full-screen player page like this one; the default
overlay mode floats them on the video, for players embedded in a
scrolling layout.
autoDispose: false means the controller outlives the widget — the
player survives rebuilds and route changes until you dispose() it in
your State.dispose. That longevity is what lets Step 4 point a second,
fully custom UI at the same kind of controller.
Step 2 — Caching that makes startup instant
That useCache: false is deliberate: these demos opt out so every run
exercises the honest network-streaming path. Drop the flag and
VideoCacheConfig() with no arguments is already the interesting part —
these are its defaults:
VideoCacheConfig(
useCache: true, // default
preCacheSize: 500 * 1024, // head bytes held in RAM (500 KB)
maxCacheSize: 100 * 1024 * 1024, // disk budget (100 MB)
key: null, // set when URLs carry changing tokens
)
The cache pre-fetches the head of the file, so when the engine asks for
the item the first bytes are served instantly while a Range request — an
HTTP request for “the rest of the file, from byte N” — streams the
remainder. Repeat plays come from disk, offline included — and both
screens in this app play the same URL, so with caching on, whichever you
open second starts from the cache. If your CDN URLs embed expiring
tokens, set key: to a stable identifier so the cache recognizes the
same content across URL changes; useCache: false (or the
VideoCacheConfig.none preset) opts a source out entirely, as these
screens do.
Step 3 — Fullscreen, rotation, and the minimize gesture
The built-in controls handle fullscreen, but leaving it well needs one decision from you. The minimize button only exists in landscape, so when it’s tapped the phone is physically sideways — if you simply allowed all orientations again, iOS would follow the device straight back to landscape. The fix is YouTube’s: lock to portrait, then re-enable free rotation only once the user physically turns the phone upright.
void _forcePortrait() {
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
_armUnlockOnPortrait();
}
void _armUnlockOnPortrait() {
_disarmUnlockOnPortrait();
void listener() {
if (DeviceOrientationListener.current == DeviceOrientation.portraitUp) {
_disarmUnlockOnPortrait();
SystemChrome.setPreferredOrientations(DeviceOrientation.values);
}
}
_unlockOnPortrait = listener;
DeviceOrientationListener.currentNotifier.addListener(listener);
}
DeviceOrientationListener.currentNotifier reports the physical
orientation even while the UI is locked — that’s what makes the “wait
until upright” trick possible. Two housekeeping notes from the finished
screen: dispose() always restores
SystemChrome.setPreferredOrientations(DeviceOrientation.values) so no
other screen inherits the lock, and a 500 ms Timer.periodic calls
setState so MediaQuery.orientationOf picks up rotation (dartnative
has no InheritedWidget-based orientation observer yet) — the screen uses
it to drop the AppBar in landscape.
Step 4 — Your own UI on the raw player
The second screen throws the ready-made controls away. VideoPlayer is
just the video surface — no buttons, no scrubber:
VideoPlayer(controller: _controller, aspectRatio: 16 / 9)
Everything else is ordinary Dart widgets in a Stack above it, driven
by one listener:
_controller.addListener(_onControllerUpdate);
The controller notifies on every position and state change; the listener
calls setState, and the build re-reads whatever it needs —
_controller.isPlaying, _controller.positionMs,
_controller.durationMs — while taps call straight back in:
void _playPause() {
final c = _controller;
if (c.positionMs >= c.durationMs && c.durationMs > 0) {
c.seekTo(Duration.zero); // ended → replay from the top
c.play();
} else if (c.isPlaying) {
c.pause();
setState(() => _showControls = true);
_hideTimer?.cancel();
} else {
c.play();
_startHideTimer();
}
}
The overlay itself is three layers: a transparent full-size tap target
(tap toggles the controls, double-tap play/pauses), a centered transport
row — replay-10 / play-pause / forward-10 — and time labels. A 3-second
Timer hides the controls during playback; they’re hidden with
Opacity(opacity: 0) rather than removed, so the Stack’s child list
never changes shape between frames.
Step 5 — A scrubber that doesn’t snap back
The thin red progress line at the video’s bottom edge is the part worth studying, because naive scrubbers glitch. While the finger is down, the UI must trust the finger, not the controller:
void _onPanUpdate(DragUpdateDetails details, double barWidth) {
setState(() {
_dragFraction = (details.localPosition.dx / barWidth).clamp(0.0, 1.0);
});
}
void _commitSeek() {
if (_dragFraction != null && _controller.durationMs > 0) {
final ms = (_dragFraction! * _controller.durationMs).toInt();
_seekTargetMs = ms;
_controller.seekTo(Duration(milliseconds: ms));
}
}
On release, _commitSeek() sends a single seekTo — but the engine
takes a moment to get there, and if you handed the bar back to
positionMs immediately it would snap to the old position and slide
over. So the listener keeps showing the held fraction until playback
catches up:
// Clear held drag fraction once controller catches up.
if (_seekTargetMs != null && !_dragging && _dragFraction != null) {
final diff = (_controller.positionMs - _seekTargetMs!).abs();
if (diff < 500) {
_dragFraction = null;
_seekTargetMs = null;
}
}
Two hit-testing details make the drag reliable: the bar’s
GestureDetector uses HitTestBehavior.opaque, so its (mostly
transparent) 44-pt strip claims touches instead of letting them fall
through to the tap-target layer below — and it’s the Stack’s last
child, so nothing above it can steal the pan.
Why this is native
The pixels come straight from AVPlayer and ExoPlayer — hardware decoding, HLS/DASH adaptive streaming, correct audio-session and Picture-in-Picture behavior, all maintained by Apple and Google. A Flutter
video_playercomposites frames into its own canvas through a texture; here the platform’s video surface is simply in the view hierarchy, with Dart widgets composing above it like any other view.
The finished code
In the public repo — dn create ., dn run. The two screens under
lib/screens/media/ are the playground’s Video Playback and Custom
Controls demos byte-for-byte (demo_ui.dart is the playground’s shared
UI kit, also verbatim); lib/main.dart adds only a thin two-row
launcher. When the playground screens improve, this tutorial inherits it
by copying the files again.