Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/video_player/video_player/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
## NEXT

* Automatic Bitrate Streaming support to Android and iOS.
* Updates minimum supported SDK version to Flutter 3.32/Dart 3.8.
* Updates README to reflect currently supported OS versions for the latest
versions of the endorsed platform implementations.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import 'dart:async';
import 'package:video_player_platform_interface/video_player_platform_interface.dart';

/// Automatic Adaptive Bitrate Manager for HLS Streaming
/// Manages quality automatically based on buffering and network conditions
class AdaptiveBitrateManager {
final int playerId;
final VideoPlayerPlatform _platform;

late Timer _monitoringTimer;
int _bufferingCount = 0;
int _currentQualityLevel = 0;
DateTime _lastQualityChange = DateTime.now();
bool _isMonitoring = false;

// Quality presets (bits per second)
static const int quality360p = 500000; // 500 kbps
static const int quality480p = 800000; // 800 kbps
static const int quality720p = 1200000; // 1.2 Mbps
static const int quality1080p = 2500000; // 2.5 Mbps

AdaptiveBitrateManager({
required this.playerId,
required VideoPlayerPlatform platform,
}) : _platform = platform;

/// Start automatic quality monitoring and adjustment
Future<void> startAutoAdaptiveQuality() async {
if (_isMonitoring) return;
_isMonitoring = true;

try {
await _platform.setBandwidthLimit(playerId, 0);
} catch (e) {
print('[AdaptiveBitrate] Error starting: $e');
_isMonitoring = false;
return;
}

_monitoringTimer = Timer.periodic(const Duration(seconds: 3), (_) async {
await _analyzeAndAdjust();
});
}

/// Record a buffering event
void recordBufferingEvent() {
if (_isMonitoring) _bufferingCount++;
}

/// Analyze network conditions and adjust quality
Future<void> _analyzeAndAdjust() async {
// Don't adjust too frequently
if (DateTime.now().difference(_lastQualityChange).inSeconds < 5) {
return;
}

int newQuality = _selectOptimalQuality();

if (newQuality != _currentQualityLevel) {
try {
await _platform.setBandwidthLimit(playerId, newQuality);
_currentQualityLevel = newQuality;
_lastQualityChange = DateTime.now();
_bufferingCount = 0;
} catch (e) {
print('[AdaptiveBitrate] Error adjusting quality: $e');
}
}
}

/// Select optimal quality based on buffering and network conditions
int _selectOptimalQuality() {
// Conservative approach: start high, lower on buffering
if (_bufferingCount > 5) {
return quality360p; // Heavy buffering
}

if (_bufferingCount > 2) {
return quality480p; // Moderate buffering
}

if (_bufferingCount == 0) {
return quality1080p; // No buffering - try high quality
}

return quality720p; // Default middle quality
}

/// Stop automatic quality management
void dispose() {
_isMonitoring = false;
_monitoringTimer.cancel();
}
}
51 changes: 51 additions & 0 deletions packages/video_player/video_player/lib/video_player.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import 'package:flutter/services.dart';
import 'package:video_player_platform_interface/video_player_platform_interface.dart';

import 'src/closed_caption_file.dart';
import 'src/adaptive_bitrate_manager.dart';

export 'package:video_player_platform_interface/video_player_platform_interface.dart'
show
Expand All @@ -24,6 +25,7 @@ export 'package:video_player_platform_interface/video_player_platform_interface.
VideoViewType;

export 'src/closed_caption_file.dart';
export 'src/adaptive_bitrate_manager.dart';

VideoPlayerPlatform? _lastVideoPlayerPlatform;

Expand Down Expand Up @@ -409,6 +411,7 @@ class VideoPlayerController extends ValueNotifier<VideoPlayerValue> {
Completer<void>? _creatingCompleter;
StreamSubscription<dynamic>? _eventSubscription;
_VideoAppLifeCycleObserver? _lifeCycleObserver;
AdaptiveBitrateManager? _adaptiveBitrateManager;

/// The id of a player that hasn't been initialized.
@visibleForTesting
Expand Down Expand Up @@ -473,6 +476,14 @@ class VideoPlayerController extends ValueNotifier<VideoPlayerValue> {
(await _videoPlayerPlatform.createWithOptions(creationOptions)) ??
kUninitializedPlayerId;
_creatingCompleter!.complete(null);

// Initialize automatic adaptive bitrate management for HLS
_adaptiveBitrateManager = AdaptiveBitrateManager(
playerId: _playerId,
platform: _videoPlayerPlatform,
);
await _adaptiveBitrateManager!.startAutoAdaptiveQuality();

final initializingCompleter = Completer<void>();

// Apply the web-specific options
Expand Down Expand Up @@ -523,6 +534,7 @@ class VideoPlayerController extends ValueNotifier<VideoPlayerValue> {
value = value.copyWith(buffered: event.buffered);
case VideoEventType.bufferingStart:
value = value.copyWith(isBuffering: true);
_adaptiveBitrateManager?.recordBufferingEvent();
case VideoEventType.bufferingEnd:
value = value.copyWith(isBuffering: false);
case VideoEventType.isPlayingStateUpdate:
Expand Down Expand Up @@ -569,6 +581,7 @@ class VideoPlayerController extends ValueNotifier<VideoPlayerValue> {
if (!_isDisposed) {
_isDisposed = true;
_timer?.cancel();
_adaptiveBitrateManager?.dispose();
await _eventSubscription?.cancel();
await _videoPlayerPlatform.dispose(_playerId);
}
Expand Down Expand Up @@ -735,6 +748,44 @@ class VideoPlayerController extends ValueNotifier<VideoPlayerValue> {
await _applyPlaybackSpeed();
}

/// Sets the bandwidth limit for HLS adaptive bitrate streaming.
///
/// This method limits the maximum bandwidth used for video playback,
/// which affects which HLS variant streams the player can select.
///
/// The native player will only select video variants with bitrate
/// less than or equal to the specified [maxBandwidthBps].
///
/// Platforms:
/// - **Android**: Uses ExoPlayer's DefaultTrackSelector.setMaxVideoBitrate()
/// - **iOS/macOS**: Uses AVPlayer's preferredPeakBitRate property
/// - **Web**: Not supported (no-op)
///
/// Parameters:
/// - [maxBandwidthBps]: Maximum bandwidth in bits per second.
/// * 0 or negative: No limit (player auto-selects)
/// * Positive value: Player selects variants ≤ this bandwidth
///
/// Example:
/// ```dart
/// // Limit to 720p quality (~1.2 Mbps)
/// await controller.setBandwidthLimit(1200000);
///
/// // No limit - let player decide
/// await controller.setBandwidthLimit(0);
/// ```
///
/// Note: This is useful for HLS streams where you want to control
/// quality selection without reinitializing the player. The player
/// will seamlessly switch to appropriate variants as bandwidth
/// changes within the limit.
Future<void> setBandwidthLimit(int maxBandwidthBps) async {
if (_isDisposedOrNotInitialized) {
return;
}
await _videoPlayerPlatform.setBandwidthLimit(_playerId, maxBandwidthBps);
}

/// Sets the caption offset.
///
/// The [offset] will be used when getting the correct caption for a specific position.
Expand Down
6 changes: 3 additions & 3 deletions packages/video_player/video_player/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ dependencies:
flutter:
sdk: flutter
html: ^0.15.0
video_player_android: ^2.8.1
video_player_avfoundation: ^2.7.0
video_player_platform_interface: ^6.3.0
video_player_android: ^2.9.3
video_player_avfoundation: ^2.9.1
video_player_platform_interface: ^6.6.0
video_player_web: ^2.1.0

dev_dependencies:
Expand Down
4 changes: 4 additions & 0 deletions packages/video_player/video_player_android/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## NEXT

* Automatic Bitrate Streaming support to Android.

## 2.9.2

* Bumps kotlin_version to 2.3.0.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,23 @@
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.media3.common.C;
import androidx.media3.common.Format;
import androidx.media3.common.PlaybackException;
import androidx.media3.common.Player;
import androidx.media3.common.Tracks;
import androidx.media3.common.VideoSize;
import androidx.media3.exoplayer.ExoPlayer;

public abstract class ExoPlayerEventListener implements Player.Listener {
private boolean isInitialized = false;
protected final ExoPlayer exoPlayer;
protected final VideoPlayerCallbacks events;

// Track current video quality for adaptive streaming logging
private int currentVideoWidth = 0;
private int currentVideoHeight = 0;
private int currentVideoBitrate = 0;

protected enum RotationDegrees {
ROTATE_0(0),
ROTATE_90(90),
Expand Down Expand Up @@ -54,51 +61,132 @@ public ExoPlayerEventListener(
@Override
public void onPlaybackStateChanged(final int playbackState) {
PlatformPlaybackState platformState = PlatformPlaybackState.UNKNOWN;

switch (playbackState) {
case Player.STATE_BUFFERING:
platformState = PlatformPlaybackState.BUFFERING;
android.util.Log.d("ExoPlayerListener", "State: BUFFERING");
break;
case Player.STATE_READY:
platformState = PlatformPlaybackState.READY;
if (!isInitialized) {
isInitialized = true;
sendInitialized();
android.util.Log.d("ExoPlayerListener", "State: READY - Video initialized");
} else {
android.util.Log.d("ExoPlayerListener", "State: READY");
}
break;
case Player.STATE_ENDED:
platformState = PlatformPlaybackState.ENDED;
android.util.Log.d("ExoPlayerListener", "State: ENDED");
break;
case Player.STATE_IDLE:
platformState = PlatformPlaybackState.IDLE;
android.util.Log.d("ExoPlayerListener", "State: IDLE");
break;
}
events.onPlaybackStateChanged(platformState);
}

@Override
public void onPlayerError(@NonNull final PlaybackException error) {
String errorMessage = error.getMessage();
if (errorMessage == null) {
errorMessage = "Unknown error";
}
android.util.Log.e("ExoPlayerListener", "Player error: " + errorMessage, error);

if (error.errorCode == PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW) {
// See
// https://exoplayer.dev/live-streaming.html#behindlivewindowexception-and-error_code_behind_live_window
android.util.Log.d("ExoPlayerListener", "Behind live window - seeking to default position");
exoPlayer.seekToDefaultPosition();
exoPlayer.prepare();
} else {
events.onError("VideoError", "Video player had error " + error, null);
events.onError("VideoError", "Video player had error " + errorMessage, null);
}
}

@Override
public void onIsPlayingChanged(boolean isPlaying) {
android.util.Log.d("ExoPlayerListener", "Is playing changed: " + isPlaying);
events.onIsPlayingStateUpdate(isPlaying);
}

@Override
public void onTracksChanged(@NonNull Tracks tracks) {
// Log adaptive bitrate streaming quality changes
logCurrentVideoQuality(tracks);

// Find the currently selected audio track and notify
String selectedTrackId = findSelectedAudioTrackId(tracks);
android.util.Log.d("ExoPlayerListener", "Tracks changed - Selected audio track: " + selectedTrackId);
events.onAudioTrackChanged(selectedTrackId);
}

@Override
public void onVideoSizeChanged(@NonNull VideoSize videoSize) {
// This is called when adaptive bitrate streaming changes the video resolution
if (currentVideoWidth != videoSize.width || currentVideoHeight != videoSize.height) {
currentVideoWidth = videoSize.width;
currentVideoHeight = videoSize.height;

String quality = getQualityLabel(videoSize.height);
android.util.Log.i("ExoPlayerListener",
"📹 ADAPTIVE QUALITY CHANGE: " + quality + " (" + videoSize.width + "x" + videoSize.height + ")");
}
}

/**
* Logs the current video quality being played (for adaptive streaming monitoring)
*/
private void logCurrentVideoQuality(@NonNull Tracks tracks) {
// Find selected video track
for (Tracks.Group group : tracks.getGroups()) {
if (group.getType() == C.TRACK_TYPE_VIDEO && group.isSelected()) {
for (int i = 0; i < group.length; i++) {
if (group.isTrackSelected(i)) {
Format format = group.getTrackFormat(i);

// Only log if quality changed
if (currentVideoBitrate != format.bitrate ||
currentVideoWidth != format.width ||
currentVideoHeight != format.height) {

currentVideoBitrate = format.bitrate;
currentVideoWidth = format.width;
currentVideoHeight = format.height;

String quality = getQualityLabel(format.height);
String bitrate = format.bitrate != Format.NO_VALUE ?
String.format("%.2f Mbps", format.bitrate / 1_000_000.0) : "unknown";

android.util.Log.i("ExoPlayerListener",
"🎬 ADAPTIVE STREAMING: Now playing " + quality + " at " + bitrate +
" [" + format.width + "x" + format.height + "]");
}
return;
}
}
}
}
}

/**
* Gets a human-readable quality label based on video height
*/
private String getQualityLabel(int height) {
if (height >= 2160) return "4K";
if (height >= 1440) return "1440p";
if (height >= 1080) return "1080p";
if (height >= 720) return "720p";
if (height >= 480) return "480p";
if (height >= 360) return "360p";
if (height >= 240) return "240p";
return height + "p";
}

/**
* Finds the ID of the currently selected audio track.
*
Expand All @@ -121,4 +209,4 @@ private String findSelectedAudioTrackId(@NonNull Tracks tracks) {
}
return null;
}
}
}
Loading