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.0 in your pubspec (the regenerated registrant loads its symbols — just keep DartNativePluginRegistrant.registerAll() first in main())

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_player composites 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.

Open the finished code →