better_native_video_player_plus
A Flutter plugin for native video playback on iOS and Android with advanced features.
π What's New β In-Video Advertisements (VAST / VMAP via Google IMA)
The plugin now supports in-video advertisements on iOS and Android through the official Google IMA SDK. You can play pre-roll, mid-roll, post-roll, and VMAP-scheduled ad breaks with normalized Dart events, full skip handling, ad pods, and automatic content resume β all without changing the existing content player API.
- β
Pre-roll / mid-roll / post-roll / multiple mid-rolls via a typed
adBreaksschedule - β VAST and VMAP tags, with the schedule owned by IMA for VMAP
- β Google IMA on both platforms: native Media3 IMA on Android, official IMA CocoaPod on iOS
- β Normalized ad events (request, ready, started, quartiles, pause/resume, skip, click, complete, break, all-ads-completed, error)
- β
Ad pods (
adPositionInPod/totalAdsInPod) and skip behavior (isSkippable,skipTimeOffset) - β Error-safe by default β a failed ad resumes content and releases native ad resources
- β
Optional β a controller without
adConfigurationbehaves exactly like an ordinary content player Advertisements are strictly opt-in. PassingadConfigurationtoload()/loadUrl()enables them; omitting it never changes content playback.
β οΈ Before you start: read Getting Ads Working Without Errors for the platform setup, the supported/unsupported combinations, and the most common failure causes. Ads run on Android API 24+ and iOS 12+ only.
Jump straight to the full Advertisement Support section for the complete API, or copy one of these ready-to-use snippets:
// Pre-roll (VAST) β plays an ad before the content.
await controller.loadUrl(
url: 'https://example.com/video.mp4',
adConfiguration: NativeVideoPlayerAdConfiguration.vast(
adTagUrl: Uri.parse('https://ads.example.com/pre-roll-vast.xml'),
adBreaks: const <NativeVideoPlayerAdBreak>[
NativeVideoPlayerAdBreak.preRoll(id: 'intro-ad'),
],
),
);
// VMAP β Google IMA owns the whole pre/mid/post-roll schedule.
await controller.loadUrl(
url: 'https://example.com/episode.m3u8',
adConfiguration: NativeVideoPlayerAdConfiguration.vmap(
adTagUrl: Uri.parse('https://ads.example.com/schedule-vmap.xml'),
),
);
// React to every ad event through the controller-scoped stream.
final sub = controller.advertisementController.events.listen((event) {
if (event.type == NativeVideoPlayerAdEventType.error) {
print('ad error: ${event.error?.code}');
}
});
Features
- β Native video players: AVPlayerViewController on iOS and ExoPlayer (Media3) on Android
- β Multiple video formats: HLS streams (.m3u8), MP4, and other common formats
- β Local file support: Play videos from device storage using file:// URIs
- β Asset video support: Play videos bundled in Flutter assets
- β HLS streaming support with adaptive quality selection
- β Video looping: Smooth native video looping without stuttering
- β Picture-in-Picture (PiP) mode on both platforms with automatic state management
- β AirPlay support on iOS with availability detection and connection events
- β Native fullscreen playback with Dart-side fullscreen option
- β In-Video Ads (IN-Ads) π: pre-roll, mid-roll, post-roll and VMAP schedules via Google IMA (VAST/VMAP) with normalized events, ad pods, skip handling, and error-safe content resume β see full guide
- β Custom overlay controls - Build your own UI on top of native player
- β Now Playing integration (Control Center on iOS, lock screen notifications on Android)
- β Background playback with media notifications
- β Playback controls: play, pause, seek, volume, speed (0.25x - 2.0x)
- β Quality selection for HLS streams with real-time switching
- β Subtitle/Closed Caption support for HLS streams (VOD and Live) with language selection and adjustable embedded-caption text size
- β Sidecar subtitles: load external VTT/SRT files (URL, file, or raw content) with fully styleable, positionable rendering
- β Audio track selection: list and switch alternate audio renditions (languages, audio descriptions)
- β
Resume positions:
load(startAt:)applied natively before the first frame +PositionCheckpointsfor persistence - β
A-B loop / clip ranges:
setPlaybackRange(start, end, loop:) - β Playlists: sequential playback with auto-advance on one controller
- β Playback analytics: startup time, stall count/duration, watched time, quality switches as a single event stream
- β Scrub-preview storyboards: parse WebVTT storyboards and sprite-sheet grids (Vimeo/Bunny style) for thumbnail previews
- β Chromecast: pure-Dart device discovery + full cast session (load with metadata/captions, play/pause/seek, volume, loop, live status stream) β no Cast SDK
- β
Offline downloads:
VideoDownloadControllerwith progress streams, persistence, cancel/remove, and local playback - β Performance tuning: global config for concurrent-playback caps, viewport-based quality capping (with lossless headroom control), lightweight inline views, playback priority, buffer presets
- β
Disk cache + precache (Android): opt-in Media3 cache with LRU eviction and a byte-budgeted
NativeVideoPlayerCache.precache()for upcoming feed items β cached items replay offline - β Texture rendering mode (experimental, opt-in): render inline tiles as Flutter textures on both platforms β native-feeling feed scrolling, no hybrid-composition overhead, automatic platform-view fallback for PiP/fullscreen/DRM
- β Separated event streams: Activity events (play/pause/buffering) and Control events (quality/speed/PiP/fullscreen)
- β Individual property streams: Dedicated streams for position, duration, speed, state, fullscreen, PiP, AirPlay, and quality
- β Real-time playback position tracking with buffered position indicator
- β Custom HTTP headers support for video requests
- β DRM (Digital Rights Management) support for protected content (FairPlay on iOS, Widevine on Android, AES-128, ClearKey)
- β Multiple controller instances support with shared player management
- β WASM compatible - Package works with Web Assembly runtime
Platform Support
| Platform | Minimum Version |
|---|---|
| iOS | 12.0+ |
| Android | API 24+ (Android 7.0) |
Supported Video Formats
The plugin supports various video formats through native platform players:
Remote URLs
- HLS Streams (.m3u8): Adaptive streaming with quality selection
- MP4 Videos: Direct MP4 video URLs
- Other formats: Any format supported by the native player (MP4, MOV, M4V on iOS; MP4, WebM, MKV on Android)
Local Files
- Device Storage: Videos stored on device using
file://URIs - App Bundle: Videos bundled with your app (iOS: via
NSBundle, Android: via assets or external storage)
Examples
Remote Videos
// HLS stream with quality selection
await controller.loadUrl(url: 'https://example.com/video.m3u8');
// MP4 video
await controller.loadUrl(url: 'https://example.com/video.mp4');
// With custom headers
await controller.loadUrl(
url: 'https://example.com/video.mp4',
headers: {'Referer': 'https://example.com'},
);
Local Files
// Android - Load from external storage
await controller.loadFile(path: '/storage/emulated/0/DCIM/video.mp4');
// iOS - Load from app documents
await controller.loadFile(path: '/var/mobile/Media/DCIM/100APPLE/video.MOV');
// Using path_provider
import 'package:path_provider/path_provider.dart';
final directory = await getApplicationDocumentsDirectory();
await controller.loadFile(path: '${directory.path}/my_video.mp4');
Generic Method (Backward Compatible)
// The generic load() method also works with both URLs and file:// URIs
await controller.load(url: 'https://example.com/video.m3u8');
await controller.load(url: 'file:///path/to/video.mp4');
Note: Quality selection and adaptive streaming are only available for HLS streams. Other formats play at their native quality.
Advertisement Support
Advertisements are optional. A controller without adConfiguration behaves as
an ordinary content player.
Advertising is provided by the Google IMA SDK:
- Android β the official Google IMA client-side SDK on top of Media3 ExoPlayer (platform-view mode).
- iOS β the official
GoogleAds-IMA-iOS-SDKCocoaPod on top of AVPlayer.
You describe what to play with a provider-neutral Dart configuration
(NativeVideoPlayerAdConfiguration), and the native IMA adapter requests the
VAST/VMAP tag, selects media, reports tracking events, and (for VMAP) owns the
schedule. No VAST or VMAP document is ever parsed in Dart.
Ad Placements at a Glance
| Placement | How to configure | When it plays |
|---|---|---|
| Pre-roll | NativeVideoPlayerAdBreak.preRoll(id: ...) |
Before content starts |
| Mid-roll | NativeVideoPlayerAdBreak.midRoll(id: ..., position: ...) |
At a content position |
| Post-roll | NativeVideoPlayerAdBreak.postRoll(id: ...) |
After content completes |
| VMAP schedule | NativeVideoPlayerAdConfiguration.vmap(...) |
Whatever the VMAP response defines |
Quick Start β Pre-roll in Three Steps
// 1. Create the controller as usual.
final controller = NativeVideoPlayerController(id: 1, autoPlay: true);
await controller.initialize();
// 2. Listen for ad events (optional but recommended).
controller.advertisementController.events.listen((event) {
print('ad event: ${event.type}');
});
// 3. Load content with an ad configuration.
await controller.loadUrl(
url: 'https://example.com/video.mp4',
adConfiguration: NativeVideoPlayerAdConfiguration.vast(
adTagUrl: Uri.parse('https://ads.example.com/pre-roll-vast.xml'),
adBreaks: const <NativeVideoPlayerAdBreak>[
NativeVideoPlayerAdBreak.preRoll(id: 'intro-ad'),
],
),
);
That is the minimum needed for a working pre-roll. Everything below is optional tuning, or the full reference for the other placements.
Order matters: create the controller, call initialize(), subscribe to the
ad streams, and then call load(...) with the adConfiguration. The ad
configuration is stored on the controller for the duration of that content load,
so a later load() without adConfiguration disables ads for the new item.
Normal Video Without Ads
Omitting adConfiguration is the default and requires no other change:
final controller = NativeVideoPlayerController(id: 1, autoPlay: true);
await controller.loadUrl(
url: 'https://example.com/video.m3u8',
);
controller.advertisementController.isEnabled stays false and no advertising
platform commands are sent.
Pre-roll
await controller.loadUrl(
url: 'https://example.com/video.mp4',
adConfiguration: NativeVideoPlayerAdConfiguration.vast(
adTagUrl: Uri.parse('https://ads.example.com/pre-roll-vast.xml'),
adBreaks: const <NativeVideoPlayerAdBreak>[
NativeVideoPlayerAdBreak.preRoll(id: 'intro-ad'),
],
),
);
Mid-roll and Multiple Mid-rolls
await controller.loadUrl(
url: 'https://example.com/episode.m3u8',
adConfiguration: NativeVideoPlayerAdConfiguration.vast(
adTagUrl: Uri.parse('https://ads.example.com/episode-vast.xml'),
adBreaks: const <NativeVideoPlayerAdBreak>[
NativeVideoPlayerAdBreak.midRoll(
id: 'break-10m',
position: Duration(minutes: 10),
),
NativeVideoPlayerAdBreak.midRoll(
id: 'break-20m',
position: Duration(minutes: 20),
),
],
),
);
Post-roll
await controller.loadUrl(
url: 'https://example.com/episode.mp4',
adConfiguration: NativeVideoPlayerAdConfiguration.vast(
adTagUrl: Uri.parse('https://ads.example.com/post-roll-vast.xml'),
adBreaks: const <NativeVideoPlayerAdBreak>[
NativeVideoPlayerAdBreak.postRoll(id: 'outro-ad'),
],
),
);
VAST
Use NativeVideoPlayerAdConfiguration.vast with a VAST tag URL. Google IMA
handles wrappers, redirects, media selection, tracking, click-through, skip
offsets, quartile events, pods, and errors. VAST documents are not parsed in
Dart.
await controller.loadUrl(
url: 'https://example.com/video.mp4',
adConfiguration: NativeVideoPlayerAdConfiguration.vast(
adTagUrl: Uri.parse('https://ads.example.com/tag.xml'),
adBreaks: const <NativeVideoPlayerAdBreak>[
NativeVideoPlayerAdBreak.preRoll(id: 'pre'),
],
),
);
A VAST response typically describes one break. To place ads at several
positions, add one adBreaks entry (or use VMAP instead β see below).
VMAP
await controller.loadUrl(
url: 'https://example.com/episode.m3u8',
adConfiguration: NativeVideoPlayerAdConfiguration.vmap(
adTagUrl: Uri.parse('https://ads.example.com/schedule-vmap.xml'),
),
);
For VMAP, Google IMA owns the complete pre-roll, mid-roll, post-roll, and
multiple-break schedule. Manual adBreaks are ignored when tagType is VMAP.
Selecting Between VAST and VMAP
| You want to⦠| Use | Who controls placement |
|---|---|---|
| Place a break at a position you choose in Dart | ...vast(...) + adBreaks |
Your adBreaks list |
| Let the ad server define the whole schedule | ...vmap(...) |
The VMAP response (IMA) |
| Single simple pre-roll | ...vast(...) + one preRoll |
Your adBreaks list |
If you are unsure, start with VAST + a pre-roll: it is the smallest moving part and exercises the same native path as the other placements.
Ad Events
Normalized events are available from the existing controller event architecture:
final subscription = controller.advertisementController.events.listen((event) {
print('${event.type} ad=${event.metadata?.adId} break=${event.adBreakId}');
print('content=${event.contentId} at=${event.timestamp?.toUtc()}');
});
Events include request, loaded, started, quartile, paused, resumed, skipped, clicked, completed, ad-break, all-ads-completed, and error notifications.
Error Handling
Ad failures resume content by default and release native ad resources:
adConfiguration: NativeVideoPlayerAdConfiguration.vast(
adTagUrl: Uri.parse('https://ads.example.com/tag.xml'),
resumeContentOnError: true,
)
Set resumeContentOnError: false to keep the advertisement session failed for
an application-managed recovery flow. The content player remains the playback
authority in either case.
What VAST, VMAP, and IMA Do
- VAST is the ad response/tag format. It describes ads, media files, tracking, click-through behavior, and skip information.
- VMAP is a schedule format. It describes where pre-roll, mid-roll, and post-roll breaks occur, including multiple breaks.
- Google IMA requests and plays those tags, follows supported wrappers and redirects, selects media, reports tracking/quartile events, and owns VMAP scheduling on Android and iOS.
VAST -> advertisement response and tracking metadata
VMAP -> advertisement break schedule
IMA -> requests, playback, tracking, and native integration
When VMAP is selected, its schedule is authoritative and manual adBreaks
are not scheduled by Dart. When VAST is selected, the package can use manual
pre-roll, mid-roll, and post-roll breaks.
Complete Manual Schedule
final controller = NativeVideoPlayerController(id: 42, autoPlay: true);
await controller.loadUrl(
url: 'https://example.com/episode.m3u8',
adConfiguration: NativeVideoPlayerAdConfiguration.vast(
adTagUrl: Uri.parse('https://ads.example.com/episode.xml'),
adBreaks: const <NativeVideoPlayerAdBreak>[
NativeVideoPlayerAdBreak.preRoll(id: 'pre'),
NativeVideoPlayerAdBreak.midRoll(
id: '10-minutes',
position: Duration(minutes: 10),
),
NativeVideoPlayerAdBreak.midRoll(
id: '20-minutes',
position: Duration(minutes: 20),
),
NativeVideoPlayerAdBreak.midRoll(
id: '30-minutes',
position: Duration(minutes: 30),
),
NativeVideoPlayerAdBreak.postRoll(id: 'post'),
],
),
);
Manual mid-roll breaks are identified by their stable id. Once a break has
played, seeking backward does not immediately replay it during the same load.
The content position is saved before the break and restored afterward.
Ad Pods and Skip Behavior
IMA can return an ad pod for a break. The native ad player keeps content
paused for the pod and resumes it after the pod or a recoverable failure.
The normalized event includes adPositionInPod and totalAdsInPod when IMA
provides them.
Skip behavior is also IMA/VAST-driven. A skippable ad exposes isSkippable
and skipTimeOffset in NativeVideoPlayerAdMetadata; the built-in overlay
shows Skip in X until the offset and then enables Skip Ad. Non-skippable
ads do not show an enabled skip action. Applications can also provide an
application-level NativeVideoPlayerAdSkipConfiguration, but it cannot make
a non-skippable served ad skippable.
Advertisement Events
Listen through the existing controller-scoped event architecture:
final subscription = controller.advertisementController.events.listen((event) {
switch (event.type) {
case NativeVideoPlayerAdEventType.requestStarted:
case NativeVideoPlayerAdEventType.breakReady:
case NativeVideoPlayerAdEventType.breakStarted:
case NativeVideoPlayerAdEventType.adStarted:
case NativeVideoPlayerAdEventType.firstQuartile:
case NativeVideoPlayerAdEventType.midpoint:
case NativeVideoPlayerAdEventType.thirdQuartile:
case NativeVideoPlayerAdEventType.adPaused:
case NativeVideoPlayerAdEventType.adResumed:
case NativeVideoPlayerAdEventType.adSkipped:
case NativeVideoPlayerAdEventType.adCompleted:
case NativeVideoPlayerAdEventType.breakCompleted:
case NativeVideoPlayerAdEventType.allAdsCompleted:
case NativeVideoPlayerAdEventType.clicked:
print('ad event: ${event.type}');
case NativeVideoPlayerAdEventType.adProgress:
print('ad progress: ${event.position} / ${event.duration}');
case NativeVideoPlayerAdEventType.error:
print('ad error: ${event.error?.code}');
case NativeVideoPlayerAdEventType.unknown:
break;
}
});
Events carry the event type, ad metadata, ad-break ID, pod position, content request context, and a UTC timestamp when available.
Advertisement State
controller.advertisementController.state exposes disabled, idle,
requesting, ready, playing, paused, completed, skipped, error,
and disposed. sessionState additionally distinguishes content phases from
adLoading, adPlaying, adPaused, adCompleted, adSkipped, and
adFailed. The stateStream and sessionStateStream properties can be used
to drive application UI.
Player Behavior During Ads
The content player remains the authority. During an ad, content playback is paused and the built-in ad overlay replaces custom content controls. Content seeking, quality, subtitle, and audio-track actions are not initiated by the ad overlay. Native IMA controls retain platform-appropriate ad interaction; volume, mute, and fullscreen remain available where the native platform allows them. The overlay disappears when content resumes.
Content and Lifecycle Compatibility
Advertising works with the existing HLS and MP4 content paths; it does not
rewrite or permanently insert ads into the content media file. During a
mid-roll, the current content position is saved and restored. Ad resources
are released during view/controller disposal and route resource release.
Replay or a new load(..., force: true) creates a fresh ad session. App
background/foreground, fullscreen, and platform-view recreation retain the
existing shared-player lifecycle; native IMA resources are cleaned up when a
view or controller is released.
Advertising Platform Support
| Platform | Content playback | Google IMA ads |
|---|---|---|
| Android API 24+ | Yes | Yes, native platform-view mode |
| iOS 12+ | Yes | Yes, official IMA CocoaPod |
| Windows | No plugin native player | No |
| macOS | No plugin native player | No |
| Linux | No plugin native player | No |
| Web/WASM | API compatibility only | No native IMA adapter |
On unsupported platforms, existing content APIs remain available where the platform implementation supports them, but native IMA advertisement commands are not available.
Android Configuration
Android uses the official Google IMA client-side SDK with Media3 ExoPlayer. The plugin targets API 24+, compiles with SDK 36, and uses Java/Kotlin 11. The IMA dependency and required desugaring are included by the plugin; no additional app dependency is required. Texture mode cannot host native IMA ad UI and reports an unsupported-ad error.
iOS Configuration
iOS uses the official GoogleAds-IMA-iOS-SDK CocoaPod through the plugin
podspec. The minimum deployment target is iOS 12.0. IMA uses a native overlay
over the existing AVPlayer content surface; no custom VAST or VMAP parser is
used. Run pod install from the example iOS project on macOS before building.
Getting Ads Working Without Errors
Follow these rules to avoid the common failure modes. Every item here maps to a concrete cause seen in real integrations.
1. Use the correct call order
final controller = NativeVideoPlayerController(id: 1);
await controller.initialize(); // 1. initialize first
controller.advertisementController // 2. subscribe before load,
.events.listen(_onAdEvent); // otherwise you miss startup events
await controller.loadUrl( // 3. then load with a configuration
url: 'https://example.com/video.mp4',
adConfiguration: NativeVideoPlayerAdConfiguration.vast(
adTagUrl: Uri.parse('https://ads.example.com/tag.xml'),
adBreaks: const <NativeVideoPlayerAdBreak>[
NativeVideoPlayerAdBreak.preRoll(id: 'pre'),
],
),
);
- Loading before
initialize()throwsController not initialized. - Subscribing after
load()can missrequestStarted/breakReady.
2. Give every break a stable, unique id
A mid-roll that has already played is not replayed when the user seeks back
during the same load (identified by its id). Reusing the same id for two
different breaks collapses them into one:
// β
Unique, stable ids
NativeVideoPlayerAdBreak.midRoll(id: 'break-10m', position: const Duration(minutes: 10)),
NativeVideoPlayerAdBreak.midRoll(id: 'break-20m', position: const Duration(minutes: 20)),
// β Two different positions, same id β treated as one break
NativeVideoPlayerAdBreak.midRoll(id: 'ad', position: const Duration(minutes: 10)),
NativeVideoPlayerAdBreak.midRoll(id: 'ad', position: const Duration(minutes: 20)),
A new load(...) (or load(..., force: true)) starts a fresh ad session, so
breaks become eligible again on a genuine reload.
3. A mid-roll requires a position; pre/post-roll must not have one
NativeVideoPlayerAdBreak asserts this. Using the named constructors correctly
already guarantees it, but constructing the raw class directly does not:
// β
Named constructors are always valid
NativeVideoPlayerAdBreak.preRoll(id: 'pre'),
NativeVideoPlayerAdBreak.midRoll(id: 'mid', position: const Duration(minutes: 5)),
NativeVideoPlayerAdBreak.postRoll(id: 'post'),
4. Mid-rolls can only fire if the platform knows the duration
A mid-roll at 10:00 cannot be scheduled until the content duration is known.
For HLS, the duration arrives after the manifest is parsed; for a live
stream there is no fixed duration, so position-based mid-rolls do not apply.
Prefer VMAP or pre/post-roll for live content.
5. Do not combine VMAP with a manual schedule
When tagType is VMAP, the VMAP response is authoritative and manual adBreaks
are ignored. Pass either a manual schedule (VAST) or VMAP β not both, and not
with the expectation that the manual list still applies.
6. Keep adConfiguration off unsupported surfaces
Native IMA ad UI is only available on Android API 24+ and iOS 12+ in platform-view mode. Ads are not available on Web/WASM, desktop (Windows/macOS/Linux), or when the inline tile is rendered in texture mode. In texture mode the ad reports an unsupported-ad error. If you rely on ads for a particular tile, keep that tile in platform-view mode:
// β Ads + texture mode on the same tile β unsupported-ad error
NativeVideoPlayerConfig.global = const NativeVideoPlayerConfig(
androidTextureMode: true,
iosTextureMode: true,
);
7. Expect and handle failures gracefully
The default behavior already protects content playback: a failed ad request or break resumes content and releases native ad resources. This is the safe default for production β leave it on unless you need a custom recovery flow:
adConfiguration: NativeVideoPlayerAdConfiguration.vast(
adTagUrl: Uri.parse('https://ads.example.com/tag.xml'),
resumeContentOnError: true, // default β content keeps playing if the ad fails
),
Always subscribe to the errors stream so failures are visible rather than
silent:
controller.advertisementController.errors.listen((error) {
// error.code / error.message are safe to log; error.details may be provider-specific
debugPrint('ad failed [${error.code}]: ${error.message}');
});
8. Test against a real ad tag on a physical device
Ad serving depends on the network and the ad server, not just your app. When
an ad does not appear, in this order check: the tag URL is reachable and
returns a valid VAST/VMAP response; the device has network access; the content
itself loads without ads (proves the content path is fine); then the ad events
β a requestStarted with no breakReady means the server returned no ad.
Common Ad Problems and Fixes
| Symptom | Likely cause | Fix |
|---|---|---|
| No ad events at all | Subscribed after load(), or no adConfiguration |
Subscribe before load(); pass a configuration |
Controller not initialized |
load() called before initialize() |
Await initialize() first |
| Mid-roll never plays | Duration unknown (live) or position beyond content length | Use VMAP/pre-roll, or a position within the content |
| Same break plays twice | Two breaks share an id |
Give each break a unique id |
| Unsupported-ad error | Texture mode, Web/WASM, or desktop | Use platform-view mode on Android/iOS |
| Ad plays, then content never resumes | resumeContentOnError: false and no recovery |
Set it back to true, or handle the failed state |
| Content pauses with no ad visible | Ad requested but server returned no creative | Handle error/breakCompleted; content resumes by default |
Manual adBreaks ignored |
Configured with VMAP | VMAP owns the schedule β remove manual breaks or switch to VAST |
Advertisement API Reference
NativeVideoPlayerAdConfiguration
Create it with NativeVideoPlayerAdConfiguration.vast(...) or
NativeVideoPlayerAdConfiguration.vmap(...) (both forward to the base
constructor with the matching tagType).
| Parameter | Type | Default | Description |
|---|---|---|---|
adTagUrl |
Uri |
required | VAST or VMAP tag URL |
tagType |
NativeVideoPlayerAdTagType |
auto (via vast/vmap) |
Tag format: auto, vast, or vmap |
enabled |
bool |
true |
Whether advertising participates in this load |
adBreaks |
List<NativeVideoPlayerAdBreak> |
const [] |
Manual pre/mid/post-roll schedule (ignored for VMAP) |
timeout |
Duration? |
null |
Optional native ad request timeout |
skipConfiguration |
NativeVideoPlayerAdSkipConfiguration? |
null |
Default skip policy for configured breaks |
requestMetadata |
NativeVideoPlayerAdRequestMetadata? |
null |
Content context sent with the request |
resumeContentOnError |
bool |
true |
Resume content automatically after an ad failure |
Convenience getters: preRollBreaks, midRollBreaks, postRollBreaks.
NativeVideoPlayerAdBreak
Built via preRoll(...), midRoll(...), or postRoll(...), or directly with an
explicit type.
| Parameter | Type | Required for | Description |
|---|---|---|---|
id |
String |
all | Stable app-defined break identifier (must be non-empty) |
type |
NativeVideoPlayerAdBreakType |
base constructor | preRoll, midRoll, or postRoll |
position |
Duration? |
midRoll only |
Content position for a mid-roll |
adTagUrl |
Uri? |
optional | Per-break tag URL overriding the configuration-level tag |
skipConfiguration |
NativeVideoPlayerAdSkipConfiguration? |
optional | Per-break skip policy override |
Other Ad Types
| Type | Purpose |
|---|---|
NativeVideoPlayerAdTagType |
auto, vast, vmap |
NativeVideoPlayerAdBreakType |
preRoll, midRoll, postRoll |
NativeVideoPlayerAdSkipConfiguration |
App-level skip gate: allowUserSkip, skipAfter |
NativeVideoPlayerAdRequestMetadata |
Request context: contentId, contentTitle, contentUrl, customParameters |
NativeVideoPlayerAdMetadata |
Served ad info: adId, creativeId, adSystem, title, advertiserName, clickThroughUrl, duration, isSkippable, skipTimeOffset |
NativeVideoPlayerAdError |
code, message, details |
NativeVideoPlayerAdEvent |
Normalized event (see below) |
NativeVideoPlayerAdEventType |
The event enum (see below) |
NativeVideoPlayerAdPlaybackState |
Ad playback state |
NativeVideoPlayerAdSessionState |
Content/ad orchestration phase |
NativeVideoPlayerAdEventType Values
requestStarted, breakReady, breakStarted, adStarted, adProgress,
firstQuartile, midpoint, thirdQuartile, adPaused, adResumed,
adSkipped, adCompleted, breakCompleted, allAdsCompleted, clicked,
error, unknown.
NativeVideoPlayerAdEvent Fields
type, rawType, adBreak, adBreakId, metadata, position, duration,
adPositionInPod, totalAdsInPod, error, timestamp (UTC),
contentId, contentTitle, contentUrl.
controller.advertisementController Members
| Member | Type | Description |
|---|---|---|
configuration |
NativeVideoPlayerAdConfiguration? |
Configuration for the current load |
isEnabled |
bool |
Whether advertising is configured |
state |
NativeVideoPlayerAdPlaybackState |
Current ad playback state |
sessionState |
NativeVideoPlayerAdSessionState |
Current content/ad phase |
contentActivityState |
PlayerActivityState |
Latest content activity state |
currentBreak |
NativeVideoPlayerAdBreak? |
Break currently being prepared or played |
lastError |
NativeVideoPlayerAdError? |
Most recent normalized error |
lastEvent |
NativeVideoPlayerAdEvent? |
Most recent normalized event |
events |
Stream<NativeVideoPlayerAdEvent> |
Normalized ad events |
errors |
Stream<NativeVideoPlayerAdError> |
Structured ad errors |
stateStream |
Stream<NativeVideoPlayerAdPlaybackState> |
Ad playback state changes |
sessionStateStream |
Stream<NativeVideoPlayerAdSessionState> |
Session phase changes |
skipAdvertisement() |
Future<void> |
Request a skip when IMA permits it |
Complete Example β Player with Ads, Events, and Skip Handling
import 'package:flutter/material.dart';
import 'package:better_native_video_player_plus/better_native_video_player_plus.dart';
class AdVideoPage extends StatefulWidget {
const AdVideoPage({super.key});
@override
State<AdVideoPage> createState() => _AdVideoPageState();
}
class _AdVideoPageState extends State<AdVideoPage> {
late final NativeVideoPlayerController _controller;
StreamSubscription<NativeVideoPlayerAdEvent>? _adSub;
StreamSubscription<NativeVideoPlayerAdError>? _errorSub;
String _adStatus = 'content';
@override
void initState() {
super.initState();
_controller = NativeVideoPlayerController(id: 7, autoPlay: true);
_initialize();
}
Future<void> _initialize() async {
await _controller.initialize();
final ads = _controller.advertisementController;
// Subscribe BEFORE load so no startup event is missed.
_adSub = ads.events.listen((event) {
if (!mounted) return;
setState(() => _adStatus = event.type.name);
switch (event.type) {
case NativeVideoPlayerAdEventType.adStarted:
debugPrint('ad started: ${event.metadata?.title}');
case NativeVideoPlayerAdEventType.adProgress:
debugPrint('ad ${event.position} / ${event.duration}');
case NativeVideoPlayerAdEventType.adSkipped:
debugPrint('ad skipped');
case NativeVideoPlayerAdEventType.allAdsCompleted:
debugPrint('all ads complete β content resumes');
default:
break;
}
});
_errorSub = ads.errors.listen((error) {
debugPrint('ad failed [${error.code}]: ${error.message}');
});
await _controller.loadUrl(
url: 'https://example.com/episode.m3u8',
adConfiguration: NativeVideoPlayerAdConfiguration.vast(
adTagUrl: Uri.parse('https://ads.example.com/episode.xml'),
adBreaks: const <NativeVideoPlayerAdBreak>[
NativeVideoPlayerAdBreak.preRoll(id: 'pre'),
NativeVideoPlayerAdBreak.midRoll(
id: 'mid-10m',
position: Duration(minutes: 10),
),
NativeVideoPlayerAdBreak.postRoll(id: 'post'),
],
// Content keeps playing if the ad request fails (default).
resumeContentOnError: true,
),
);
}
@override
void dispose() {
_adSub?.cancel();
_errorSub?.cancel();
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Ads: $_adStatus')),
body: Column(
children: [
NativeVideoPlayer(controller: _controller),
// Optional app-level skip button β only effective for skippable ads.
TextButton(
onPressed: () =>
_controller.advertisementController.skipAdvertisement(),
child: const Text('Skip Ad (if skippable)'),
),
],
),
);
}
}
DRM Support
The plugin supports Digital Rights Management (DRM) for protected content playback on both iOS and Android platforms.
Supported DRM Types
| Platform | DRM Type | Description |
|---|---|---|
| iOS | FairPlay Streaming | Apple's DRM solution for HLS content |
| iOS | AES-128 | Standard HLS encryption (no license server required) |
| Android | Widevine | Google's DRM solution for protected content |
| Android | AES-128 | Standard HLS encryption (no license server required) |
| Both | ClearKey | Unencrypted key system for testing and development |
DRM Configuration
DRM is configured via the drmConfig parameter in the load(), loadUrl(), and loadFile() methods:
await controller.loadUrl(
url: 'https://example.com/protected-stream.m3u8',
drmConfig: {
'type': 'fairplay', // or 'widevine', 'aes-128', 'clearKey'
'licenseUrl': 'https://license.server.com/get',
'certificateUrl': 'https://cert.server.com/cert.der', // iOS FairPlay only
'headers': {
'Authorization': 'Bearer <token>',
'X-Custom-Header': 'value',
},
},
);
DRM Configuration Parameters
| Parameter | Type | Required | Platform | Description |
|---|---|---|---|---|
type |
String |
Yes | Both | DRM type: 'fairplay', 'widevine', 'aes-128', or 'clearKey' |
licenseUrl |
String |
Yes* | Both | License server URL for key requests (*not required for AES-128) |
certificateUrl |
String |
Yes (FairPlay) | iOS | Certificate URL for FairPlay DRM |
headers |
Map<String, String> |
No | Both | HTTP headers for license requests (authentication, etc.) |
Platform-Specific Examples
iOS - FairPlay Streaming
await controller.loadUrl(
url: 'https://example.com/fairplay-stream.m3u8',
drmConfig: {
'type': 'fairplay',
'licenseUrl': 'https://license.server.com/fairplay',
'certificateUrl': 'https://cert.server.com/fairplay.der',
'headers': {
'Authorization': 'Bearer your-token-here',
},
},
);
FairPlay Requirements:
- Certificate URL must be provided
- Certificate is automatically fetched and used for license requests
- License server must implement FairPlay Streaming protocol
- Works with HLS streams only
Android - Widevine
await controller.loadUrl(
url: 'https://example.com/widevine-stream.m3u8',
drmConfig: {
'type': 'widevine',
'licenseUrl': 'https://license.server.com/widevine',
'headers': {
'Authorization': 'Bearer your-token-here',
'Content-Type': 'application/octet-stream',
},
},
);
Widevine Requirements:
- License server URL is required
- License server must implement Widevine protocol
- Supports both HLS and DASH streams
- Works with ExoPlayer's DRM framework
AES-128 (Standard HLS Encryption)
AES-128 is standard HLS encryption that doesn't require a license server. The encryption keys are embedded in the HLS manifest:
await controller.loadUrl(
url: 'https://example.com/aes128-stream.m3u8',
drmConfig: {
'type': 'aes-128',
// No licenseUrl or certificateUrl needed
// Keys are automatically extracted from the HLS manifest
},
);
AES-128 Notes:
- No license server required
- Keys are automatically extracted from the HLS manifest
- Works on both iOS and Android
- Most common encryption for HLS streams
ClearKey (Testing/Development)
ClearKey is an unencrypted key system useful for testing and development:
await controller.loadUrl(
url: 'https://example.com/clearkey-stream.m3u8',
drmConfig: {
'type': 'clearKey',
'licenseUrl': 'https://license.server.com/clearkey',
},
);
ClearKey Notes:
- Primarily for testing and development
- Not recommended for production use
- Useful for debugging DRM integration
DRM Best Practices
- Secure License Requests: Always use HTTPS for license URLs and include authentication headers
- Error Handling: Implement proper error handling for DRM failures (license denied, network errors, etc.)
- Certificate Management: For FairPlay, ensure your certificate URL is accessible and returns valid DER-encoded certificates
- Testing: Test DRM playback on physical devices (simulators may have limitations)
- Platform Differences: Be aware that FairPlay (iOS) and Widevine (Android) have different license request formats
DRM Error Handling
DRM errors are reported through the standard player event system:
_controller.addActivityListener((event) {
if (event.state == PlayerActivityState.error) {
final errorMessage = event.data?['message'] as String?;
print('DRM Error: $errorMessage');
// Handle DRM-specific errors
}
});
Common DRM errors:
- License server unavailable
- Invalid certificate (FairPlay)
- License denied by server
- Network errors during license request
- Unsupported DRM type for platform
Installation
Add this to your package's pubspec.yaml file:
dependencies:
better_native_video_player: ^0.4.10
Then run:
flutter pub get
iOS Setup
This plugin supports both CocoaPods and Swift Package Manager (SPM). Flutter will automatically use the appropriate dependency manager based on your project configuration.
Add the following to your Info.plist:
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<!-- For background audio/video playback and Picture-in-Picture -->
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<!-- Only if you use Chromecast discovery (CastDeviceDiscovery): iOS 14+
requires these for mDNS on the local network. The first scan triggers
the Local Network permission prompt. -->
<key>NSLocalNetworkUsageDescription</key>
<string>Used to find Cast devices on your network.</string>
<key>NSBonjourServices</key>
<array>
<string>_googlecast._tcp</string>
</array>
For Picture-in-Picture support, you can either:
Option 1: Manual Info.plist configuration (as shown above)
- Add both
audioandpicture-in-picturetoUIBackgroundModes
Option 2: Xcode Capabilities interface
- Target β Signing & Capabilities β "+ Capability" β Background Modes
- Check "Audio, AirPlay, and Picture in Picture"
- This will automatically add both
audioandpicture-in-pictureto your Info.plist
Note: Both audio and picture-in-picture capabilities are required for:
- Automatic Picture-in-Picture when app goes to background (iOS 14.2+)
- Background audio playback
- AirPlay functionality
Android Setup
The plugin automatically configures the required permissions and services in its manifest.
For Picture-in-Picture support, Android PiP is handled by the floating package. The integration is automatic - no additional setup required! The floating package provides:
- Automatic PiP when the home button is pressed (if
canStartPictureInPictureAutomaticallyis enabled) - Manual PiP entry via
controller.enterPictureInPicture() - Only the video surface is shown in PiP mode (all overlays are hidden)
Important: On Android, PiP (both manual and automatic) is only available when the video is in Dart fullscreen mode (i.e., when using custom overlay controls with overlayBuilder). This ensures only the video player is captured in PiP, not the surrounding app UI.
Note: PiP requires Android 8.0+ (API 26+) and android:supportsPictureInPicture="true" in your Activity manifest (already included by the plugin).
Usage
Basic Example
import 'package:flutter/material.dart';
import 'package:better_native_video_player_plus/better_native_video_player_plus.dart';
class VideoPlayerPage extends StatefulWidget {
const VideoPlayerPage({super.key});
@override
State<VideoPlayerPage> createState() => _VideoPlayerPageState();
}
class _VideoPlayerPageState extends State<VideoPlayerPage> {
late NativeVideoPlayerController _controller;
@override
void initState() {
super.initState();
_initializePlayer();
}
Future<void> _initializePlayer() async {
// Create controller
_controller = NativeVideoPlayerController(
id: 1,
autoPlay: true,
showNativeControls: true,
);
// Listen to events
_controller.addListener(_handlePlayerEvent);
// Initialize
await _controller.initialize();
// Load video - Multiple options:
// Option 1: Load remote URL (HLS stream)
await _controller.loadUrl(
url: 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8',
);
// Option 2: Load remote URL (MP4 video)
// await _controller.loadUrl(
// url: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
// );
// Option 3: Load local file from device storage
// await _controller.loadFile(
// path: '/storage/emulated/0/DCIM/video.mp4',
// );
// Option 4: Generic load method (also supported)
// await _controller.load(
// url: 'https://example.com/video.m3u8',
// );
// Option 5: Load with DRM (protected content)
// await _controller.loadUrl(
// url: 'https://example.com/protected-stream.m3u8',
// drmConfig: {
// 'type': 'fairplay', // or 'widevine' for Android
// 'licenseUrl': 'https://license.server.com/get',
// 'certificateUrl': 'https://cert.server.com/cert.der', // iOS FairPlay only
// 'headers': {
// 'Authorization': 'Bearer <token>',
// },
// },
// );
}
void _handlePlayerEvent(NativeVideoPlayerEvent event) {
print('Player event: ${event.type}');
}
@override
void dispose() {
_controller.removeListener(_handlePlayerEvent);
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: NativeVideoPlayer(controller: _controller),
);
}
}
Advanced Usage
Custom Media Info (Now Playing)
_controller = NativeVideoPlayerController(
id: 1,
mediaInfo: const NativeVideoPlayerMediaInfo(
title: 'My Video Title',
subtitle: 'Artist or Channel Name',
album: 'Album Name',
artworkUrl: 'https://example.com/artwork.jpg',
),
);
Picture-in-Picture Configuration
Note: On Android, PiP requires the video to be in Dart fullscreen mode (using custom overlay controls). On iOS, PiP works in both normal and fullscreen modes.
_controller = NativeVideoPlayerController(
id: 1,
allowsPictureInPicture: true,
canStartPictureInPictureAutomatically: true, // iOS 14.2+, Android 8.0+ (when in fullscreen)
);
Playback Controls
// Play/Pause
await _controller.play();
await _controller.pause();
// Seek
await _controller.seekTo(const Duration(seconds: 30));
// Volume (0.0 to 1.0)
await _controller.setVolume(0.8);
// Speed
await _controller.setSpeed(1.5); // 0.5x, 1.0x, 1.5x, 2.0x, etc.
// Fullscreen
await _controller.enterFullScreen();
await _controller.exitFullScreen();
await _controller.toggleFullScreen();
Video Looping
The plugin supports smooth native video looping on both iOS and Android:
// Enable looping at controller creation
_controller = NativeVideoPlayerController(
id: 1,
enableLooping: true,
);
// Or enable/disable looping dynamically during playback
await _controller.setLooping(true); // Enable looping
await _controller.setLooping(false); // Disable looping
Features:
- Seamless looping without visible pause or stuttering
- Native implementation for optimal performance (ExoPlayer's REPEAT_MODE_ONE on Android, automatic replay on iOS)
- Can be configured at controller creation or changed dynamically during playback
- Works with all supported video formats (HLS, MP4, local files, etc.)
Lifecycle Management
The plugin provides two methods for managing player lifecycle:
dispose() - Complete Cleanup
Fully disposes of all resources including the native player. Use this when the video player is no longer needed and will not be reused.
@override
void dispose() {
// Remove all listeners
_controller.removeActivityListener(_handleActivityEvent);
_controller.removeControlListener(_handleControlEvent);
// Fully dispose the controller
_controller.dispose();
super.dispose();
}
What dispose() does:
- Pauses playback and exits fullscreen
- Cancels all event channel subscriptions
- Clears all event handlers and listeners
- Releases Flutter resources (platform view contexts, overlay builders)
- Destroys the native player (calls platform's dispose method)
- Clears player state and URL
- Removes player from shared player manager
releaseResources() - Temporary Cleanup
Releases Flutter resources but keeps the native player alive. Useful when you need to temporarily clean up Flutter-side resources while keeping the native player running (e.g., when navigating away from a screen but want to keep the player alive for later).
@override
void dispose() {
// Release Flutter resources but keep native player alive
_controller.releaseResources();
super.dispose();
}
What releaseResources() does:
- Pauses playback and exits fullscreen
- Cancels all event channel subscriptions
- Clears all event handlers and listeners
- Releases Flutter resources (platform view contexts, overlay builders)
- Keeps the native player alive for potential reuse
When to use each method:
| Scenario | Method | Reason |
|---|---|---|
| Leaving the app or closing video permanently | dispose() |
Completely frees all resources including native player |
| Navigating between screens with same controller ID | releaseResources() |
Keeps native player alive for shared player scenarios |
| Temporarily hiding video player | releaseResources() |
Player can be quickly resumed without reloading video |
| App shutdown or logout | dispose() |
Ensures complete cleanup |
List and detail screens (same controller): Prefer one NativeVideoPlayerController instance that you pass from the list screen to the detail screen. Both can show NativeVideoPlayer(controller: sameController); the plugin supports multiple simultaneous platform views per controller and keeps playback in sync. If the list unmounts the player when not visible (e.g. to save memory), call controller.releaseResources() in the listβs dispose or visibility callback and do not call dispose() on the controller so the detail screen can keep using it. When the user navigates back, the list can build NativeVideoPlayer(controller: sameController) again; the plugin reconnects the native surface so the inline video shows correctly instead of a black screen.
Example: Shared player across screens
// List screen with thumbnail/preview
class VideoListScreen extends StatefulWidget {
@override
State<VideoListScreen> createState() => _VideoListScreenState();
}
class _VideoListScreenState extends State<VideoListScreen> {
late NativeVideoPlayerController _controller;
@override
void initState() {
super.initState();
// Use a stable controller ID for sharing
_controller = NativeVideoPlayerController(id: 100, autoPlay: false);
_controller.initialize();
_controller.load(url: 'https://example.com/video.m3u8');
}
@override
void dispose() {
// Release Flutter resources but keep native player for detail screen
_controller.releaseResources();
super.dispose();
}
Widget build(BuildContext context) {
return ListTile(
onTap: () {
// Navigate to detail screen with same controller ID
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => VideoDetailScreen(controllerId: 100),
),
);
},
// ... list item content
);
}
}
// Detail screen reuses the same controller
class VideoDetailScreen extends StatefulWidget {
final int controllerId;
const VideoDetailScreen({required this.controllerId, super.key});
@override
State<VideoDetailScreen> createState() => _VideoDetailScreenState();
}
class _VideoDetailScreenState extends State<VideoDetailScreen> {
late NativeVideoPlayerController _controller;
@override
void initState() {
super.initState();
// Reuse the same controller ID - native player is still alive!
_controller = NativeVideoPlayerController(
id: widget.controllerId,
autoPlay: true,
);
_controller.initialize();
}
@override
void dispose() {
// Fully dispose when leaving detail screen permanently
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: NativeVideoPlayer(controller: _controller),
);
}
}
Quality Selection (HLS)
// Get available qualities
final qualities = _controller.qualities;
// Set quality
if (qualities.isNotEmpty) {
await _controller.setQuality(qualities.first);
}
Subtitle/Closed Caption Support
The plugin supports subtitles and closed captions for HLS streams (both VOD and Live). Subtitles must be embedded in the HLS stream manifest as text tracks.
Get Available Subtitle Tracks:
// Get all available subtitle tracks
final subtitles = await _controller.getAvailableSubtitleTracks();
// Check available languages
for (final track in subtitles) {
print('${track.displayName} (${track.language}) - Selected: ${track.isSelected}');
}
Select a Subtitle Track:
// Select a subtitle track by passing the track object
if (subtitles.isNotEmpty) {
await _controller.setSubtitleTrack(subtitles.first);
}
// Disable subtitles
await _controller.setSubtitleTrack(NativeVideoPlayerSubtitleTrack.off());
Using the Subtitle Picker Modal:
A ready-to-use subtitle picker modal is included in the example app with the following features:
- Display all available subtitle tracks
- Enable/disable subtitles
- Font size control (12-32px)
- Beautiful Material Design UI
import 'package:better_native_video_player_plus/better_native_video_player_plus.dart';
// Show the subtitle picker modal
showSubtitlePicker(
context: context,
controller: _controller,
fontSize: 16.0, // Initial font size
onFontSizeChanged: (newSize) {
// Handle font size changes
setState(() {
_subtitleFontSize = newSize;
});
},
);
Scaling Embedded Caption Text Size:
Embedded tracks are rendered by the platform caption renderers, so subtitleStyle.fontSize doesn't affect them. Use the embedded text scale instead β it takes effect live, survives quality switches and item reloads, and scales relative to the user's system caption size preference (1.0 = platform default):
// Convenience method (150% captions)
await _controller.setNativeSubtitleTextScale(1.5);
// Or drive it through the style object alongside the sidecar styling
_controller.setSubtitleStyle(
const NativeVideoPlayerSubtitleStyle(embeddedTextScale: 1.5),
);
// Back to the platform default
await _controller.setNativeSubtitleTextScale(1.0);
Example HLS Streams with Subtitles:
// Apple's example stream with multiple subtitle languages
await _controller.load(
url: 'https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_fmp4/master.m3u8',
);
// After loading, get available subtitles
final subtitles = await _controller.getAvailableSubtitleTracks();
Important Notes:
- Embedded subtitle tracks are automatically detected from the stream
- External subtitle files (SRT, VTT) are supported as sidecar subtitles β see the next section
- Both VOD (Video on Demand) and Live streams are supported
- Font rendering and styling of embedded tracks are handled by the native players (AVPlayer on iOS, ExoPlayer on Android) β only the text size can be scaled via
embeddedTextScale/setNativeSubtitleTextScale; sidecar subtitles are rendered by the plugin and fully styleable
Platform-Specific Behavior:
- iOS: Uses AVFoundation's
AVMediaSelectionGroupfor subtitle track management - Android: Uses ExoPlayer's text track selection API
- Both platforms support WebVTT and other standard subtitle formats embedded in HLS streams
See the subtitle_example_screen.dart in the example app for a complete implementation including a subtitle picker modal with font size controls.
Sidecar Subtitles (External VTT/SRT Files)
Load subtitle files that are not embedded in the stream β from a URL, a local file, or raw text β and render them in a styleable Flutter overlay. Works identically for HLS and MP4 on both platforms.
// Provide sidecar subtitles at load time...
await controller.load(
url: 'https://example.com/video.mp4',
sidecarSubtitles: const [
NativeVideoPlayerSidecarSubtitle.url(
'https://example.com/subs_en.vtt',
language: 'en',
label: 'English',
),
NativeVideoPlayerSidecarSubtitle.file(
'/path/to/dutch.srt',
language: 'nl',
label: 'Nederlands',
),
],
);
// ...or attach them later:
await controller.setSidecarSubtitles([...]);
// Sidecar tracks appear in the SAME list as embedded tracks β
// the `source` field tells them apart:
final tracks = await controller.getAvailableSubtitleTracks();
for (final track in tracks) {
print('${track.displayName} (${track.source.name})'); // embedded | sidecar
}
// Selection works through the existing API for both kinds:
await controller.setSubtitleTrack(tracks.firstWhere(
(t) => t.source == SubtitleTrackSource.sidecar,
));
Styling and positioning (text style, colors, outline, alignment, padding):
NativeVideoPlayer(
controller: controller,
subtitleStyle: const NativeVideoPlayerSubtitleStyle(
fontSize: 22,
textColor: Colors.yellow,
backgroundColor: Colors.black54,
alignment: Alignment.topCenter, // position anywhere
padding: EdgeInsets.all(24),
),
)
Platform notes:
- On Android, URL sources are also attached natively (
MediaItem.SubtitleConfiguration) so captions stay visible in PiP and native fullscreen (with platform-default styling there). - On iOS, sidecar cues render in the Flutter overlay only β they are not visible inside native fullscreen, the PiP window, or on an AirPlay receiver (the phone keeps rendering them during AirPlay). Use embedded HLS tracks when receiver-side captions are required.
Audio Track Selection
List and switch alternate audio renditions (languages, audio descriptions) on both platforms:
final tracks = await controller.getAvailableAudioTracks();
for (final track in tracks) {
print('${track.displayName} (${track.language}) selected: ${track.isSelected}');
}
await controller.setAudioTrack(tracks[1]);
An audioTrackChange control event (PlayerControlState.audioTrackChanged) is emitted on switches.
Resume Positions (startAt) and Position Checkpoints
Start playback at a stored position β applied natively before the first frame, so there is no visible seek after playback begins:
await controller.load(
url: 'https://example.com/video.mp4',
startAt: const Duration(minutes: 12, seconds: 30),
);
To persist positions, PositionCheckpoints emits the position at most once per interval plus a final value on dispose:
final checkpoints = PositionCheckpoints(
controller,
interval: const Duration(seconds: 5),
onCheckpoint: (position) => storage.save(videoId, position),
);
// later, together with the player:
checkpoints.dispose(); // flushes the last position
A-B Loop / Clip Range
// Loop a section (language practice, training clips, ...):
await controller.setPlaybackRange(
start: const Duration(seconds: 10),
end: const Duration(seconds: 25),
); // loop: true is the default
// Or play a clip once and pause at its end:
await controller.setPlaybackRange(start: a, end: b, loop: false);
controller.clearPlaybackRange(); // back to unrestricted playback
Loading a new video clears the range automatically.
Playlists
Sequential playback of multiple sources on one controller with auto-advance:
final playlist = NativeVideoPlayerPlaylist(controller, items: const [
NativeVideoPlayerPlaylistItem(url: 'https://example.com/lesson1.mp4'),
NativeVideoPlayerPlaylistItem(
url: 'https://example.com/lesson2.mp4',
startAt: Duration(seconds: 30), // per-item resume positions
),
]);
await playlist.start();
playlist.currentIndexStream.listen((i) => print('now playing $i'));
// playlist.next() / playlist.previous() / playlist.playItemAt(i)
playlist.dispose(); // detach when done
Note: auto-advance relies on the completed event, so disable setLooping while a playlist is attached.
Playback Analytics (QoE Events)
Derive quality-of-experience metrics from the existing event streams β no extra platform traffic:
final analytics = PlaybackAnalytics(controller);
analytics.events.listen((event) {
// startup (ms), stallStarted/stallEnded (+duration), seeked,
// qualityChanged, watchedHeartbeat (watched ms), completed
metrics.track(event.type.name, event.value);
});
print(analytics.stallCount);
print(analytics.watchedDuration);
analytics.dispose();
Background Playback Guard
For players that should not keep playing when the app is backgrounded (the plugin supports background playback by default):
final guard = BackgroundPlaybackGuard(controller); // pauses on background
guard.pauseInBackground = false; // flip at runtime (e.g. a user setting)
guard.dispose();
PiP and AirPlay sessions are deliberately left running, and a video the user paused themselves stays paused on return.
Scrub-Preview Storyboards (Thumbnail Previews)
Show thumbnail previews while scrubbing. Both WebVTT storyboards and uniform sprite-sheet grids (what Vimeo's thumb_preview and Bunny Stream's seek/_N.jpg actually serve) are supported:
// WebVTT storyboard ("url#xywh=x,y,w,h" cues):
final board = await StoryboardThumbnails.fromUrl('https://cdn.example.com/storyboard.vtt');
// Or a uniform sprite grid (Vimeo thumb_preview / Bunny seek sheets):
final board = StoryboardThumbnails.fromUniformGrid(
spriteUrls: ['https://cdn.example.com/sprites.webp'],
frameInterval: const Duration(seconds: 5),
columns: 10,
frameWidth: 426,
frameHeight: 240,
framesPerSprite: 120,
);
final thumb = board.thumbnailAt(scrubPosition);
// thumb.url + thumb.region (crop rect inside the sprite sheet)
Performance Configuration
Global tuning knobs in NativeVideoPlayerConfig (set NativeVideoPlayerConfig.global before creating controllers), built for multi-video feeds:
NativeVideoPlayerConfig.global = const NativeVideoPlayerConfig(
maxConcurrentPlayingPlayers: 2, // LRU playback cap
qualityForViewportSize: true, // cap HLS quality to the tile size
viewportCapHeadroom: 1.5, // iOS cap headroom (1.5 = visually lossless, 1.0 = max savings)
prioritizeActivePlayback: true, // Android: playing > preloading bandwidth
lightweightInlineViews: true, // lighter native views when controls are hidden
androidBufferConfig: NativeVideoPlayerAndroidBufferConfig.feed(),
iosBufferConfig: NativeVideoPlayerIosBufferConfig.feed(),
);
All flags default to off / current behavior. See PERFORMANCE_ROADMAP.md for the measured impact of each knob on real devices.
qualityForViewportSizeβ caps each player's ABR variant selection to its on-screen size, so a feed of small tiles stops decoding several full-resolution streams at once. Measured on a Galaxy S21: β58% Dalvik heap at six concurrent players (172β77MB), and it turns an OOM-crash sequence into a survivable one. The cap lifts automatically for fullscreen and AirPlay; manual quality selection is never constrained.viewportCapHeadroom(iOS) β multiplier applied to the viewport cap. The default1.5keeps the first HLS variant at-or-above the tile size selectable (visually lossless); set1.0for maximum savings at the cost of one ladder step of sharpness.lightweightInlineViewsβ when a tile hides native controls (showNativeControls: false), renders it with a bareAVPlayerLayer(iOS) /SurfaceView+ subtitle overlay (Android) instead of a fullAVPlayerViewController/ Media3PlayerView. Fullscreen, PiP (including automatic PiP on backgrounding), Now Playing and AirPlay all still work β verified on physical devices.prioritizeActivePlayback(Android) β playing tiles get network/IO priority over paused/preloading ones via Media3'sPriorityTaskManager.
Disk Cache and Precaching (Android)
Opt-in Media3 disk cache so revisited feed items skip the network, plus a precache API for upcoming items:
NativeVideoPlayerConfig.global = const NativeVideoPlayerConfig(
androidEnableDiskCache: true,
androidDiskCacheMaxBytes: 100 * 1024 * 1024, // LRU-evicted, default 100MB
androidPrecacheBytes: 2 * 1024 * 1024, // per-precache budget, default 2MB
);
// Warm the cache for the next items in your feed (fire-and-forget):
await NativeVideoPlayerCache.precache('https://example.com/video.m3u8');
Works for both progressive (MP4) and HLS sources β HLS precaching warms the playlists plus the leading segments up to the byte budget. DRM-protected and non-HTTP sources bypass the cache automatically. Cached items replay without a network connection. iOS is intentionally not covered (AVFoundation has no practical inline HLS cache); the call is a silent no-op there.
Texture Rendering Mode (Experimental)
By default every player is a native platform view. With texture mode, inline tiles render as ordinary Flutter textures instead:
NativeVideoPlayerConfig.global = const NativeVideoPlayerConfig(
androidTextureMode: true,
iosTextureMode: true,
);
What you gain: feed scrolling behaves like a normal Flutter list (platform views claim drag gestures that start on a video and kill fling momentum β textures don't), the hybrid-composition overhead disappears, and tiles participate in normal Flutter compositing (clips, transforms, RepaintBoundary).
What it costs and the contract:
- Video frames are composited by the Flutter raster thread, which is more expensive while many large tiles play simultaneously on mid-range Android devices. Modern iPhones absorb it easily. Measure for your content size β see
PERFORMANCE_ROADMAP.md. - Texture mode only applies to tiles with hidden native controls outside fullscreen hosts; other tiles automatically stay platform views.
- iOS PiP: tiles with
canStartPictureInPictureAutomaticallykeep using (light) platform views so automatic PiP works unchanged. ManualenterPictureInPicture()from a texture tile transparently swaps the tile to a platform view first (same shared player, visually seamless), then starts PiP. - Fullscreen from a texture tile uses the Dart fullscreen player (native fullscreen needs a platform view).
- FairPlay DRM requires platform-view mode (iOS); AirPlay from a texture tile keeps playing on the receiver but freezes the local preview on the last frame.
Companion Package: WebView-Free Vimeo/YouTube Extraction
The repo ships a separate package, packages/better_native_video_extractor, that resolves Vimeo (and YouTube) videos to playable HLS/MP4 URLs, thumbnails, durations and storyboards over plain HTTP β no hidden WebViews. Supports Referer headers for domain-locked Vimeo videos and an expiry-aware cache:
final cache = VideoExtractionCache(VimeoExtractor(referer: 'https://yourdomain.com'));
final video = await cache.extract('https://vimeo.com/76979871');
await controller.load(url: video.playbackUrl!);
Image.network(video.bestThumbnail!.url);
Chromecast (Google Cast)
Chromecast support without the Cast SDK: the session protocol (CASTV2 over TLS) is pure Dart, and discovery uses the system Bonjour browser on iOS (required on physical devices β raw multicast needs a restricted Apple entitlement) with pure-Dart mDNS elsewhere. It lives in a separate entrypoint so its CastDevice/CastSession names can't collide with other packages β import it with a prefix:
import 'package:better_native_video_player_plus/cast.dart' as nvp_cast;
Discover devices on the local network (mDNS):
try {
final devices = await nvp_cast.CastDeviceDiscovery.discover();
for (final d in devices) {
print('${d.displayName} (${d.model}) at ${d.host}:${d.port}');
}
} on nvp_cast.CastDiscoveryException catch (e) {
// Wrong network or missing Local Network permission (see iOS Setup) β
// never crashes the app, just surfaces actionable guidance.
print(e.message);
}
Connect and load media with metadata and caption tracks:
final session = await nvp_cast.CastSession.connect(devices.first);
await session.loadMedia(
contentUrl: 'https://example.com/video.mp4', // receiver fetches this itself:
contentType: 'video/mp4', // must be HTTPS/CORS-readable
title: 'Big Buck Bunny',
subtitle: 'Blender Foundation',
imageUrl: 'https://example.com/poster.jpg', // shows on the TV + cast dialogs
textTracks: [
nvp_cast.CastTextTrack(
trackId: 1,
url: 'https://example.com/subs_en.vtt',
language: 'en',
name: 'English',
),
],
activeTrackIds: [1], // start with captions on
startAt: const Duration(seconds: 30),
);
Full transport control, including receiver-side state sync:
await session.play();
await session.pause();
await session.seek(const Duration(minutes: 2));
await session.setVolume(0.4); // receiver volume 0..1
await session.setMuted(true);
await session.setActiveTracks([1]); // captions on; [] = off
session.setLooping(true); // reloads the media when the receiver finishes
// React to ANY change on the receiver β including changes made on the TV
// or by other senders (Google Home app, voice commands):
session.statusStream.listen((s) {
print('${s.playerState} ${s.position}/${s.duration} '
'vol ${(s.volumeLevel * 100).round()}% tracks ${s.activeTrackIds}');
});
await session.close();
Use statusStream to mirror the cast state in your own player UI (position slider, play/pause icon, volume) so the app always reflects what the TV is doing. See example/lib/screens/perf/cast_screen.dart for a complete picker + remote-control screen.
Note: the receiver downloads contentUrl, imageUrl, and track URLs itself β they must be reachable from the Chromecast (public HTTPS, CORS headers for VTT tracks). file:// and app-local paths won't work.
Offline Downloads
VideoDownloadController downloads videos for offline playback with progress reporting and a persistent index. The plugin deliberately doesn't depend on path_provider β you pass the directory:
final dir = await getApplicationDocumentsDirectory(); // path_provider
final downloads = VideoDownloadController(
directoryPath: '${dir.path}/video_downloads',
);
// Start a download β the stream emits progress and closes on a terminal
// status (completed / failed / canceled):
downloads.download(
id: 'lesson-42',
url: 'https://example.com/video.mp4',
headers: {'Authorization': 'Bearer ...'}, // optional
).listen((p) {
// p.fraction is 0..1 (null when the server sends no Content-Length)
print('${p.status} ${p.receivedBytes}/${p.totalBytes}');
});
Manage and play downloaded files:
final all = await downloads.listDownloads(); // List<VideoDownload>
final isDone = await downloads.isDownloaded('lesson-42');
final path = await downloads.localPathFor('lesson-42');
if (path != null) {
await controller.load(url: 'file://$path'); // plays fully offline
}
await downloads.cancel('lesson-42'); // stop an active download
await downloads.remove('lesson-42'); // delete file + index entry
Calling download() again for an already-downloaded id immediately emits completed; calling it while the same id is downloading returns the existing stream (no duplicate work). Partial files are written as .part and only renamed on success, so an interrupted download never leaves a corrupt "completed" file.
Separated Event Handling
The plugin separates events into two categories for better control:
Activity Events - Playback state changes:
@override
void initState() {
super.initState();
_controller.addActivityListener(_handleActivityEvent);
_controller.addControlListener(_handleControlEvent);
}
void _handleActivityEvent(PlayerActivityEvent event) {
switch (event.state) {
case PlayerActivityState.playing:
print('Playing');
break;
case PlayerActivityState.paused:
print('Paused');
break;
case PlayerActivityState.buffering:
final buffered = event.data?['buffered'] as int?;
print('Buffering... buffered position: $buffered ms');
break;
case PlayerActivityState.completed:
print('Playback completed');
break;
case PlayerActivityState.error:
print('Error: ${event.data?['message']}');
break;
default:
break;
}
}
Control Events - User interactions and settings:
void _handleControlEvent(PlayerControlEvent event) {
switch (event.state) {
case PlayerControlState.timeUpdated:
final position = event.data?['position'] as int?;
final duration = event.data?['duration'] as int?;
final bufferedPosition = event.data?['bufferedPosition'] as int?;
print('Position: $position ms / $duration ms (buffered: $bufferedPosition ms)');
break;
case PlayerControlState.qualityChanged:
final quality = event.data?['quality'];
print('Quality changed: $quality');
break;
case PlayerControlState.pipStarted:
print('PiP mode started');
break;
case PlayerControlState.pipStopped:
print('PiP mode stopped');
break;
case PlayerControlState.fullscreenEntered:
print('Entered fullscreen');
break;
case PlayerControlState.fullscreenExited:
print('Exited fullscreen');
break;
default:
break;
}
}
@override
void dispose() {
_controller.removeActivityListener(_handleActivityEvent);
_controller.removeControlListener(_handleControlEvent);
_controller.dispose();
super.dispose();
}
Individual Property Streams
For convenience, the controller also provides dedicated streams for individual properties. These are useful when you only need to listen to specific changes:
@override
void initState() {
super.initState();
// Listen to position changes
_controller.positionStream.listen((position) {
print('Position: ${position.inSeconds}s');
});
// Listen to player state changes
_controller.playerStateStream.listen((state) {
if (state == PlayerActivityState.playing) {
print('Video is playing');
}
});
// Listen to fullscreen state changes
_controller.isFullscreenStream.listen((isFullscreen) {
print('Fullscreen: $isFullscreen');
});
// Listen to PiP state changes
_controller.isPipEnabledStream.listen((isPipEnabled) {
print('PiP enabled: $isPipEnabled');
});
// Listen to speed changes
_controller.speedStream.listen((speed) {
print('Playback speed: ${speed}x');
});
// Listen to quality changes
_controller.qualityChangedStream.listen((quality) {
print('Quality: ${quality.name}');
});
}
Available streams:
bufferedPositionStream- Stream of buffered position changesdurationStream- Stream of duration changesplayerStateStream- Stream of player state changes (playing, paused, buffering, etc.)positionStream- Stream of playback position changesspeedStream- Stream of playback speed changesisPipEnabledStream- Stream of Picture-in-Picture state changesisPipAvailableStream- Stream of Picture-in-Picture availability changesisAirplayAvailableStream- Stream of AirPlay availability changesisAirplayConnectedStream- Stream of AirPlay connection state changesisFullscreenStream- Stream of fullscreen state changesqualityChangedStream- Stream of quality changes (emits when user selects a quality)qualitiesStream- Stream of available qualities list changes (emits when quality list is loaded/updated)
Note: The original event listeners (addActivityListener, addControlListener) are still available and continue to work as before. Use whichever approach best fits your use case.
Custom HTTP Headers
await _controller.load(
url: 'https://example.com/video.m3u8',
headers: {
'Referer': 'https://example.com',
'Authorization': 'Bearer token',
},
);
Picture-in-Picture Mode
Note: On Android, PiP is only available when the video is in Dart fullscreen mode (using custom overlay controls). On iOS, PiP works in both normal and fullscreen modes.
// Check if PiP is available on the device
final isPipAvailable = await _controller.isPictureInPictureAvailable();
if (isPipAvailable) {
// Enter PiP mode
// On Android: Requires video to be in fullscreen first
await _controller.enterPictureInPicture();
// Exit PiP mode
await _controller.exitPictureInPicture();
// Or toggle PiP mode
await _controller.togglePictureInPicture();
}
// Listen for PiP state changes using the event listener
_controller.addControlListener((event) {
if (event.state == PlayerControlState.pipStarted) {
print('Entered PiP mode');
} else if (event.state == PlayerControlState.pipStopped) {
print('Exited PiP mode');
}
});
// Or listen using the dedicated stream
_controller.isPipEnabledStream.listen((isPipEnabled) {
print('PiP enabled: $isPipEnabled');
});
AirPlay (iOS Only)
AirPlay allows streaming video to Apple TV, HomePod, and other AirPlay-enabled devices.
Global AirPlay Detection:
The plugin now uses global AirPlay device detection that works across your entire app. Initialize AirPlay detection once at app startup:
// In your app initialization (e.g., main.dart or app startup)
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Create at least one controller first (required for initialization)
final controller = NativeVideoPlayerController(id: 1);
await controller.initialize();
// Initialize global AirPlay detection
await AirPlayStateManager.instance.init();
runApp(MyApp());
}
Using AirPlay in your app:
@override
void initState() {
super.initState();
// Listen for AirPlay availability changes
_controller.addAirPlayAvailabilityListener(_handleAirPlayAvailability);
// Listen for AirPlay connection state
_controller.addAirPlayConnectionListener(_handleAirPlayConnection);
}
void _handleAirPlayAvailability(bool isAvailable) {
print('AirPlay devices available: $isAvailable');
// Show/hide AirPlay button in your UI
}
void _handleAirPlayConnection(bool isConnected) {
print('Connected to AirPlay: $isConnected');
// Update UI to show AirPlay is active
}
// Check if AirPlay is available
final isAvailable = await _controller.isAirPlayAvailable();
// Show AirPlay device picker
if (isAvailable) {
await _controller.showAirPlayPicker();
}
@override
void dispose() {
_controller.removeAirPlayAvailabilityListener(_handleAirPlayAvailability);
_controller.removeAirPlayConnectionListener(_handleAirPlayConnection);
_controller.dispose();
super.dispose();
}
Benefits of Global Detection:
- AirPlay device availability is monitored once for the entire app (more efficient)
- All video player controllers automatically receive AirPlay availability updates
- Detection starts immediately at app launch for faster device discovery
- Centralized management reduces resource usage and improves battery life
Custom Overlay Controls
Build your own video controls UI on top of the native player:
NativeVideoPlayer(
controller: _controller,
overlayBuilder: (context, controller) {
return CustomVideoOverlay(controller: controller);
},
)
Create a custom overlay widget:
class CustomVideoOverlay extends StatefulWidget {
final NativeVideoPlayerController controller;
const CustomVideoOverlay({required this.controller, super.key});
@override
State<CustomVideoOverlay> createState() => _CustomVideoOverlayState();
}
class _CustomVideoOverlayState extends State<CustomVideoOverlay> {
Duration _currentPosition = Duration.zero;
Duration _duration = Duration.zero;
Duration _bufferedPosition = Duration.zero;
PlayerActivityState _activityState = PlayerActivityState.idle;
@override
void initState() {
super.initState();
widget.controller.addActivityListener(_handleActivityEvent);
widget.controller.addControlListener(_handleControlEvent);
// Get initial state
_currentPosition = widget.controller.currentPosition;
_duration = widget.controller.duration;
_bufferedPosition = widget.controller.bufferedPosition;
_activityState = widget.controller.activityState;
}
void _handleActivityEvent(PlayerActivityEvent event) {
if (!mounted) return;
setState(() {
_activityState = event.state;
});
}
void _handleControlEvent(PlayerControlEvent event) {
if (!mounted) return;
if (event.state == PlayerControlState.timeUpdated) {
setState(() {
_currentPosition = widget.controller.currentPosition;
_duration = widget.controller.duration;
_bufferedPosition = widget.controller.bufferedPosition;
});
}
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
// Center play/pause button
Center(
child: IconButton(
icon: Icon(
_activityState.isPlaying ? Icons.pause : Icons.play_arrow,
color: Colors.white,
size: 48,
),
onPressed: () {
if (_activityState.isPlaying) {
widget.controller.pause();
} else {
widget.controller.play();
}
},
),
),
// Progress bar with buffered indicator
Positioned(
bottom: 20,
left: 20,
right: 20,
child: Slider(
value: _currentPosition.inMilliseconds.toDouble(),
min: 0,
max: _duration.inMilliseconds.toDouble(),
// Shows buffered position
secondaryTrackValue: _bufferedPosition.inMilliseconds.toDouble(),
onChanged: (value) {
widget.controller.seekTo(Duration(milliseconds: value.toInt()));
},
),
),
// Fullscreen button
Positioned(
top: 20,
right: 20,
child: IconButton(
icon: Icon(
widget.controller.isFullScreen ? Icons.fullscreen_exit : Icons.fullscreen,
color: Colors.white,
),
onPressed: () {
widget.controller.toggleFullScreen();
},
),
),
],
);
}
@override
void dispose() {
widget.controller.removeActivityListener(_handleActivityEvent);
widget.controller.removeControlListener(_handleControlEvent);
super.dispose();
}
}
Features you can add to custom overlays:
- Playback controls: Play, pause, skip forward/backward
- Progress bar: Current position with buffered position indicator
- Speed controls: 0.25x, 0.5x, 0.75x, 1.0x, 1.25x, 1.5x, 1.75x, 2.0x
- Quality selector: Switch between HLS quality variants
- Fullscreen toggle: Enter/exit fullscreen
- Volume control: Adjust playback volume
- AirPlay button: Show AirPlay picker (iOS only)
- Auto-hide: Fade out controls after inactivity
- Loading indicators: Show when buffering
See example/lib/widgets/custom_video_overlay.dart for a complete implementation.
Multiple Video Players
class MultiPlayerScreen extends StatefulWidget {
@override
State<MultiPlayerScreen> createState() => _MultiPlayerScreenState();
}
class _MultiPlayerScreenState extends State<MultiPlayerScreen> {
late NativeVideoPlayerController _controller1;
late NativeVideoPlayerController _controller2;
@override
void initState() {
super.initState();
// Create multiple controllers with unique IDs
_controller1 = NativeVideoPlayerController(id: 1, autoPlay: false);
_controller2 = NativeVideoPlayerController(id: 2, autoPlay: false);
_initializePlayers();
}
Future<void> _initializePlayers() async {
await _controller1.initialize();
await _controller2.initialize();
await _controller1.load(url: 'https://example.com/video1.m3u8');
await _controller2.load(url: 'https://example.com/video2.m3u8');
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Expanded(child: NativeVideoPlayer(controller: _controller1)),
Expanded(child: NativeVideoPlayer(controller: _controller2)),
],
);
}
@override
void dispose() {
_controller1.dispose();
_controller2.dispose();
super.dispose();
}
}
API Reference
NativeVideoPlayerController
Constructor Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
id |
int |
required | Unique identifier for the player instance |
autoPlay |
bool |
false |
Start playing automatically after loading |
enableLooping |
bool |
false |
Enable automatic video looping with smooth native playback |
mediaInfo |
NativeVideoPlayerMediaInfo? |
null |
Media metadata for Now Playing |
allowsPictureInPicture |
bool |
true |
Enable Picture-in-Picture |
canStartPictureInPictureAutomatically |
bool |
true |
Auto-start PiP on app background (iOS 14.2+) |
showNativeControls |
bool |
true |
Show native player controls |
NativeVideoPlayer Widget
Constructor Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
controller |
NativeVideoPlayerController |
required | The controller for the video player |
overlayBuilder |
Widget Function(BuildContext, NativeVideoPlayerController)? |
null |
Builder for custom overlay controls on top of the native player |
Example:
NativeVideoPlayer(
controller: _controller,
overlayBuilder: (context, controller) {
return CustomVideoOverlay(controller: controller);
},
)
Overlay Interaction:
- Tapping on the video when overlay is hidden shows the overlay
- Tapping on the overlay when visible hides it (in addition to the auto-hide timer)
- Interactive elements (buttons, sliders) in the overlay work normally
- Overlay automatically hides after 3 seconds of inactivity
NativeVideoPlayerController
Methods
Initialization:
Future<void> initialize()- Initialize the controller
Loading Videos:
Future<void> load({required String url, Map<String, String>? headers, Map<String, dynamic>? drmConfig, List<NativeVideoPlayerSidecarSubtitle>? sidecarSubtitles, Duration? startAt, bool force})- Load video URL or file (generic method, backward compatible). Supports DRM, sidecar subtitles, a native resume position (startAt) andforceto replace an already-loaded video.Future<void> loadUrl({required String url, Map<String, String>? headers, Map<String, dynamic>? drmConfig, Duration? startAt, bool force})- Load remote video URL with optional HTTP headers, DRM configuration and resume positionFuture<void> loadFile({required String path})- Load local video file from device storage
DRM Configuration (drmConfig parameter):
type(String, required): DRM type -'fairplay'(iOS),'widevine'(Android),'aes-128'(both), or'clearKey'(both)licenseUrl(String, required*): License server URL for key requests (*not required for AES-128)certificateUrl(String, required for FairPlay): Certificate URL for FairPlay DRM (iOS only)headers(Map<String, String>, optional): HTTP headers for license requests (authentication tokens, etc.)
Playback Control:
Future<void> play()- Start playbackFuture<void> pause()- Pause playbackFuture<void> seekTo(Duration position)- Seek to positionFuture<void> setVolume(double volume)- Set volume (0.0-1.0)Future<void> setSpeed(double speed)- Set playback speedFuture<void> setLooping(bool looping)- Enable or disable video loopingFuture<void> setQuality(NativeVideoPlayerQuality quality)- Set video qualityFuture<void> setPlaybackRange({required Duration start, required Duration end, bool loop})- Confine playback to an A-B range (loop or pause-at-end)void clearPlaybackRange()- Remove the A-B range
Tracks (subtitles & audio):
Future<List<NativeVideoPlayerSubtitleTrack>> getAvailableSubtitleTracks()- Embedded and sidecar tracks merged (seetrack.source)Future<void> setSubtitleTrack(NativeVideoPlayerSubtitleTrack track)- Select any track from the merged list (.off()disables)Future<void> setSidecarSubtitles(List<NativeVideoPlayerSidecarSubtitle> sources)- Attach external VTT/SRT sources after loadFuture<List<NativeVideoPlayerAudioTrack>> getAvailableAudioTracks()- List alternate audio renditionsFuture<void> setAudioTrack(NativeVideoPlayerAudioTrack track)- Switch the audio rendition
Display Modes:
Future<bool> isPictureInPictureAvailable()- Check if PiP is available on deviceFuture<bool> enterPictureInPicture()- Enter Picture-in-Picture modeFuture<bool> exitPictureInPicture()- Exit Picture-in-Picture modeFuture<bool> togglePictureInPicture()- Toggle Picture-in-Picture modeFuture<void> enterFullScreen()- Enter fullscreenFuture<void> exitFullScreen()- Exit fullscreenFuture<void> toggleFullScreen()- Toggle fullscreenFuture<bool> isAirPlayAvailable()- Check if AirPlay devices are available (iOS only)Future<void> showAirPlayPicker()- Show AirPlay device picker (iOS only)void addAirPlayAvailabilityListener(void Function(bool) listener)- Listen for AirPlay availability changes (iOS only)void removeAirPlayAvailabilityListener(void Function(bool) listener)- Remove AirPlay availability listener (iOS only)void addAirPlayConnectionListener(void Function(bool) listener)- Listen for AirPlay connection state changes (iOS only)void removeAirPlayConnectionListener(void Function(bool) listener)- Remove AirPlay connection listener (iOS only)
AirPlay State Manager (Global):
AirPlayStateManager.instance.init()- Initialize global AirPlay device detection at app startup (iOS only, requires at least one controller to be created first)AirPlayStateManager.instance.dispose()- Stop global AirPlay detection and clean up resources (iOS only)void addActivityListener(void Function(PlayerActivityEvent) listener)- Add activity event listenervoid removeActivityListener(void Function(PlayerActivityEvent) listener)- Remove activity event listenervoid addControlListener(void Function(PlayerControlEvent) listener)- Add control event listenervoid removeControlListener(void Function(PlayerControlEvent) listener)- Remove control event listenerFuture<void> releaseResources()- Release Flutter resources but keep native player alive (for temporary cleanup)Future<void> dispose()- Fully dispose all resources including native player (for complete cleanup)
Properties
List<NativeVideoPlayerQuality> qualities- Available HLS quality variantsbool isFullScreen- Current fullscreen stateDuration currentPosition- Current playback positionDuration duration- Total video durationDuration bufferedPosition- How far the video has been buffereddouble volume- Current volume (0.0-1.0)NativeVideoPlayerPlaybackRange? playbackRange- Active A-B range, or nullPlayerActivityState activityState- Current activity statePlayerControlState controlState- Current control stateString? url- Current video URL
Streams
Stream<Duration> bufferedPositionStream- Stream of buffered position changesStream<Duration> durationStream- Stream of duration changesStream<PlayerActivityState> playerStateStream- Stream of player state changesStream<Duration> positionStream- Stream of playback position changesStream<double> speedStream- Stream of playback speed changesStream<bool> isPipEnabledStream- Stream of PiP state changesStream<bool> isPipAvailableStream- Stream of PiP availability changesStream<bool> isAirplayAvailableStream- Stream of AirPlay availability changesStream<bool> isFullscreenStream- Stream of fullscreen state changesStream<NativeVideoPlayerQuality> qualityChangedStream- Stream of quality changes
Activity Event States
| State | Description |
|---|---|
PlayerActivityState.idle |
Player is idle |
PlayerActivityState.initializing |
Player is initializing |
PlayerActivityState.initialized |
Player initialized |
PlayerActivityState.loading |
Video is loading |
PlayerActivityState.loaded |
Video loaded successfully |
PlayerActivityState.playing |
Playback is active |
PlayerActivityState.paused |
Playback is paused |
PlayerActivityState.buffering |
Video is buffering |
PlayerActivityState.completed |
Playback completed |
PlayerActivityState.stopped |
Playback stopped |
PlayerActivityState.error |
Error occurred |
Control Event States
| State | Description |
|---|---|
PlayerControlState.none |
No control event |
PlayerControlState.qualityChanged |
Video quality changed |
PlayerControlState.speedChanged |
Playback speed changed |
PlayerControlState.seeked |
Seek operation completed |
PlayerControlState.pipStarted |
PiP mode started |
PlayerControlState.pipStopped |
PiP mode stopped |
PlayerControlState.fullscreenEntered |
Fullscreen entered |
PlayerControlState.fullscreenExited |
Fullscreen exited |
PlayerControlState.timeUpdated |
Playback time updated |
PlayerControlState.subtitleTrackChanged |
Subtitle track changed |
PlayerControlState.audioTrackChanged |
Audio track changed |
Advertisement API
| Type | Purpose |
|---|---|
NativeVideoPlayerAdConfiguration |
Per-load ad setup: tag URL, tagType (vast/vmap/auto), adBreaks, timeout, skip policy, request metadata, resumeContentOnError |
NativeVideoPlayerAdBreak |
One placement: preRoll / midRoll(position:) / postRoll, with a stable id |
NativeVideoPlayerAdvertisementController |
Reached via controller.advertisementController: state, streams, currentBreak, skipAdvertisement() |
NativeVideoPlayerAdEvent / NativeVideoPlayerAdEventType |
Normalized ad events and event enum |
NativeVideoPlayerAdMetadata |
Served-ad info (adId, title, isSkippable, skipTimeOffset, β¦) |
NativeVideoPlayerAdError |
Structured ad failure (code, message, details) |
NativeVideoPlayerAdPlaybackState / NativeVideoPlayerAdSessionState |
Ad playback state and content/ad phase enums |
NativeVideoPlayerAdSkipConfiguration |
App-level skip gate (allowUserSkip, skipAfter) |
NativeVideoPlayerAdRequestMetadata |
Content context sent with the ad request |
See Advertisement Support for examples and the full reference.
Companion Helpers
| Class | Purpose |
|---|---|
NativeVideoPlayerPlaylist |
Sequential playback with auto-advance on one controller |
PlaybackAnalytics |
QoE event stream (startup, stalls, watched time, completion) |
PositionCheckpoints |
Throttled resume-position reporting with final flush on dispose |
BackgroundPlaybackGuard |
Pause on app background, resume on return (PiP/AirPlay exempt) |
StoryboardThumbnails |
Scrub-preview thumbnails from storyboard VTT or sprite grids |
VideoDownloadController |
Offline downloads: progress stream, persistent index, cancel/remove |
CastDeviceDiscovery ΒΉ |
Chromecast discovery via mDNS (_googlecast._tcp) |
CastSession ΒΉ |
Full Chromecast control: load/captions/transport/volume/loop + status stream |
ΒΉ Exported from package:better_native_video_player_plus/cast.dart (separate entrypoint β import with a prefix).
Architecture
iOS
- Uses
AVPlayerViewControllerfor video playback - Implements
FlutterPlatformViewfor embedding native views - Supports HLS streaming with native
AVPlayer - Picture-in-Picture via
AVPictureInPictureController - Now Playing info via
MPNowPlayingInfoCenter
Android
- Uses ExoPlayer (Media3) for video playback
- Implements
PlatformViewwithAndroidView - HLS support via Media3 HLS extension
- Picture-in-Picture via floating package
- Media notifications via
MediaSessionService
Troubleshooting
Common Issues
Controller not initializing:
// Always call initialize() before load()
await _controller.initialize();
await _controller.load(url: 'https://example.com/video.m3u8');
Events not firing:
// Make sure to add listeners BEFORE calling initialize()
_controller.addActivityListener(_handleActivityEvent);
_controller.addControlListener(_handleControlEvent);
await _controller.initialize();
Multiple controllers interfering:
// Ensure each controller has a unique ID
final controller1 = NativeVideoPlayerController(id: 1);
final controller2 = NativeVideoPlayerController(id: 2);
Shared controllers with automatic PiP:
// When using the same controller ID across multiple views (e.g., list + detail screen),
// automatic PiP will be enabled on the most recently active view
final listController = NativeVideoPlayerController(
id: 1, // Same ID
canStartPictureInPictureAutomatically: true,
);
final detailController = NativeVideoPlayerController(
id: 1, // Same ID - shares the player instance
canStartPictureInPictureAutomatically: true,
);
// When navigating to detail screen, automatic PiP transfers to that view
// This works for both programmatic playback and native control playback
Memory leaks:
// Always remove listeners and dispose controllers properly
@override
void dispose() {
// Remove all listeners first
_controller.removeActivityListener(_handleActivityEvent);
_controller.removeControlListener(_handleControlEvent);
_controller.removeAirPlayAvailabilityListener(_handleAirPlayAvailability);
_controller.removeAirPlayConnectionListener(_handleAirPlayConnection);
// Choose the appropriate disposal method:
// - Use dispose() for complete cleanup (recommended in most cases)
// - Use releaseResources() only for shared player scenarios
_controller.dispose();
super.dispose();
}
Note: See the Lifecycle Management section for details on when to use dispose() vs releaseResources().
Advertisements not playing or behaving unexpectedly:
- Ensure
load()is called with anadConfigurationβ a plainload()plays content with advertising disabled. - Ensure
initialize()runs (and is awaited) beforeload(). - Subscribe to
controller.advertisementController.eventsand.errorsbeforeload(), otherwise startup events are lost. - Verify the VAST/VMAP tag URL is reachable from the device and returns a valid response.
- Do not mix a manual
adBreaksschedule withtagType: vmapβ VMAP owns the schedule. - Give every break a unique, stable
id; duplicate ids collapse into a single break. - Position-based mid-rolls need a known content duration; they do not apply to live streams.
- Ads require platform-view mode; on Web/WASM, desktop, or texture-mode tiles the native IMA UI is unavailable.
- See Getting Ads Working Without Errors and the ad troubleshooting table for a complete symptom/cause/fix list.
iOS
Video doesn't play:
- Ensure
Info.plisthasNSAppTransportSecurityconfigured for HTTP videos - For HTTPS with self-signed certificates, add exception domains
- For local files, ensure proper file access permissions
- Check that the video format is supported by AVPlayer (HLS, MP4, MOV)
PiP not working:
- Required: Add
picture-in-picturetoUIBackgroundModesin Info.plist (in addition toaudio)<key>UIBackgroundModes</key> <array> <string>audio</string> <string>picture-in-picture</string> </array> - OR enable via Xcode: Target β Signing & Capabilities β Background Modes β Check "Audio, AirPlay, and Picture in Picture"
- Ensure iOS version is 14.0+ (check with
await controller.isPictureInPictureAvailable()) - For automatic PiP when app goes to background, iOS 14.2+ is required and
canStartPictureInPictureAutomaticallymust betrue(default) - PiP requires video to be playing before entering PiP mode
- Some simulators don't support PiP; test on a physical device
Now Playing not showing:
// Provide mediaInfo when creating the controller
_controller = NativeVideoPlayerController(
id: 1,
mediaInfo: const NativeVideoPlayerMediaInfo(
title: 'Video Title',
subtitle: 'Artist Name',
),
);
Background audio stops:
- Verify Background Modes are enabled in Xcode capabilities
- Ensure "Audio, AirPlay, and Picture in Picture" is checked
Android
Video doesn't play:
- Check internet permissions in your app's
AndroidManifest.xml:
<uses-permission android:name="android.permission.INTERNET" />
- Ensure minimum SDK version is 24+ in
build.gradle:
minSdkVersion 24
- For HTTPS issues, check your network security configuration
- Verify ExoPlayer supports the video format (HLS, MP4, WebM)
PiP not working:
- Required: On Android, PiP is only available when the video is in Dart fullscreen mode (using custom overlay controls with
overlayBuilder) - Enter fullscreen first before entering PiP:
await controller.enterFullScreen(); - PiP requires Android 8.0+ (API 26+)
- Check device support:
await controller.isPictureInPictureAvailable() - Android PiP is handled by the floating package - no MainActivity configuration needed
- For automatic PiP when pressing home button, set
canStartPictureInPictureAutomatically: true(default) - For manual PiP, call
await controller.enterPictureInPicture() - Only the video surface is shown in PiP mode; all overlays are automatically hidden
- The plugin automatically configures
android:supportsPictureInPicture="true"in its manifest
Fullscreen issues:
- The plugin handles fullscreen natively using a Dialog on Android
- Fullscreen works automatically; no additional configuration needed
- Ensure proper activity lifecycle management
- If orientation is locked, fullscreen may not rotate automatically
Orientation restoration:
- The plugin automatically saves and restores orientation preferences when entering/exiting fullscreen
- To specify app orientation preferences, use the
preferredOrientationsparameter:final controller = NativeVideoPlayerController( id: 1, preferredOrientations: [DeviceOrientation.portraitUp], ); - Alternatively, use
FullscreenManager.setPreferredOrientations()before entering fullscreen - When exiting fullscreen, the plugin automatically restores your specified orientations
Media notifications not showing:
- The plugin automatically configures
MediaSessionService - Ensure foreground service permissions are granted (handled automatically)
- Media info must be provided via
mediaInfoparameter - Notifications appear when video is playing in background
ExoPlayer errors:
- Check logcat for detailed error messages
- Common issues:
- Network timeouts: Check internet connectivity
- Unsupported format: Verify video codec compatibility
- DRM content: This plugin doesn't support DRM (yet)
General Debugging
Enable verbose logging:
// Check player state
print('Activity State: ${_controller.activityState}');
print('Control State: ${_controller.controlState}');
print('Is Fullscreen: ${_controller.isFullScreen}');
print('Current Position: ${_controller.currentPosition}');
print('Duration: ${_controller.duration}');
Test with known working URLs:
// HLS stream (with quality selection)
const hlsUrl = 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8';
// MP4 video (direct playback)
const mp4Url = 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4';
// Another MP4 example
const mp4Url2 = 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4';
Platform-specific issues:
import 'dart:io';
if (Platform.isIOS) {
// iOS-specific code
} else if (Platform.isAndroid) {
// Android-specific code
}
Example App
See the example folder for a complete working example demonstrating:
Features Demonstrated
- Video List with Inline Players: Multiple video players in a scrollable list
- Full-Screen Video Detail Page: Dedicated page with comprehensive controls
- Custom Overlay Controls: Complete example of building custom video controls
- AirPlay Integration: AirPlay button with availability and connection tracking (iOS)
- Playback Controls: Play, pause, seek (Β±10 seconds), volume control
- Speed Adjustment: 0.25x, 0.5x, 0.75x, 1.0x, 1.25x, 1.5x, 1.75x, 2.0x playback speeds
- Quality Selection: Automatic quality detection and manual selection for HLS streams
- Picture-in-Picture: Enter/exit PiP mode with state tracking
- Fullscreen Toggle: Both native and Dart-side fullscreen support
- Real-time Statistics: Current position, duration, buffered position tracking
- Separated Event Handling: Activity and control events with detailed logging
- Custom Media Info: Now Playing integration with metadata
- Buffered Position Indicator: Visual representation of how much video has been preloaded
- In-Video Ads: pre-roll / mid-roll / post-roll and VMAP configurations with live ad-event and error logging
- Chromecast: device scan, connect, load with captions, full remote control with live status (
screens/perf/cast_screen.dart) - Offline Downloads: progress bar, cancel/remove, offline playback (
screens/perf/download_screen.dart) - Sidecar Subtitles & Audio Tracks: external VTT/SRT styling demo and multi-audio HLS selection
- Player Features: startAt/resume, A-B loop, playlist auto-advance, analytics, Vimeo extractor demos
Running the Example
cd example
flutter run
The example includes:
video_list_screen_with_players.dart- Multiple inline video playersvideo_detail_screen_full.dart- Full-featured video player with controlsvideo_with_overlay_screen.dart- Custom overlay controls demonstrationcustom_video_overlay.dart- Complete custom overlay implementation with play/pause, progress bar, speed controls, quality selection, volume, AirPlay button, and auto-hide functionalityvideo_player_card.dart- Reusable video player widgetvideo_item.dart- Video model with sample HLS streams
License
MIT License - see LICENSE file for details
Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
Credits
Developed for the Flutter community. Based on native video player implementations using industry-standard libraries:
- iOS: AVFoundation
- Android: ExoPlayer (Media3)
Libraries
- better_native_video_player
- A Flutter plugin for native video playback on iOS and Android.
- better_native_video_player_plus
- A Flutter plugin for native video playback on iOS and Android.
- cast
- Chromecast support: device discovery (system Bonjour on iOS, pure-Dart mDNS elsewhere) and a full CASTV2 session (load with metadata + caption tracks, play/pause/seek/stop, volume/mute, caption switching, loop, live status stream).