diff --git a/packages/video_player/video_player/CHANGELOG.md b/packages/video_player/video_player/CHANGELOG.md index 46f39a8d9d4a..19816da9b8a5 100644 --- a/packages/video_player/video_player/CHANGELOG.md +++ b/packages/video_player/video_player/CHANGELOG.md @@ -1,3 +1,8 @@ +## 2.12.0 + +* Adds `setBandwidthLimit` method for adaptive bitrate streaming control. +* Adds internal `AdaptiveBitrateManager` for automatic quality adjustment based on buffering events. + ## 2.11.1 * Optimizes caption retrieval with binary search. diff --git a/packages/video_player/video_player/lib/src/adaptive_bitrate_manager.dart b/packages/video_player/video_player/lib/src/adaptive_bitrate_manager.dart new file mode 100644 index 000000000000..95c22de1fd87 --- /dev/null +++ b/packages/video_player/video_player/lib/src/adaptive_bitrate_manager.dart @@ -0,0 +1,138 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart' show visibleForTesting; +import 'package:video_player_platform_interface/video_player_platform_interface.dart'; + +/// Manages adaptive bitrate selection for HLS/DASH streaming. +/// +/// Monitors buffering events and adjusts the maximum bandwidth limit +/// to help the native player select appropriate quality variants. +/// +/// This acts as a supervisory controller on top of the native player's +/// own ABR logic (ExoPlayer's [AdaptiveTrackSelection] on Android, +/// AVFoundation on iOS). It forces quality down via [setBandwidthLimit] +/// when persistent buffering is detected, and relaxes the limit when +/// playback stabilizes. +class AdaptiveBitrateManager { + /// Creates an [AdaptiveBitrateManager] for the given [playerId]. + /// + /// The optional [clock] parameter is exposed for testing and defaults + /// to [DateTime.now]. + AdaptiveBitrateManager({ + required this.playerId, + required VideoPlayerPlatform platform, + @visibleForTesting DateTime Function()? clock, + }) : _platform = platform, + _clock = clock ?? DateTime.now; + + /// The player ID this manager controls. + final int playerId; + final VideoPlayerPlatform _platform; + final DateTime Function() _clock; + + Timer? _monitoringTimer; + int _bufferingCount = 0; + int _currentBandwidthLimit = qualityUnlimited; + late DateTime _lastQualityChange = _clock(); + bool _isMonitoring = false; + + /// Bandwidth cap for 360p quality (~500 kbps). + static const int quality360p = 500000; + + /// Bandwidth cap for 480p quality (~800 kbps). + static const int quality480p = 800000; + + /// Bandwidth cap for 720p quality (~1.2 Mbps). + static const int quality720p = 1200000; + + /// Bandwidth cap for 1080p quality (~2.5 Mbps). + static const int quality1080p = 2500000; + + /// No bandwidth limit — lets the native player choose freely. + static const int qualityUnlimited = 0; + + static const Duration _monitorInterval = Duration(seconds: 3); + static const Duration _qualityChangeCooldown = Duration(seconds: 5); + + /// Buffering count decay factor applied each monitoring cycle. + /// + /// This allows recovery to higher quality after transient buffering. + static const double _bufferingDecayFactor = 0.7; + + /// Starts automatic quality monitoring and adjustment. + /// + /// Removes any existing bandwidth limit and begins periodic analysis. + /// Safe to call multiple times — subsequent calls are no-ops. + Future startAutoAdaptiveQuality() async { + if (_isMonitoring) { + return; + } + _isMonitoring = true; + + try { + await _platform.setBandwidthLimit(playerId, qualityUnlimited); + } catch (e) { + _isMonitoring = false; + return; + } + + _monitoringTimer = Timer.periodic(_monitorInterval, (_) { + _analyzeAndAdjust(); + }); + } + + /// Records a buffering event from the player. + /// + /// Called by [VideoPlayerController] when a [bufferingStart] event occurs. + void recordBufferingEvent() { + if (_isMonitoring) { + _bufferingCount++; + } + } + + /// Analyzes recent buffering history and adjusts the bandwidth limit. + Future _analyzeAndAdjust() async { + if (_clock().difference(_lastQualityChange) < _qualityChangeCooldown) { + return; + } + + final int newLimit = _selectOptimalBandwidth(); + + // Apply decay so transient buffering doesn't permanently pin quality low. + _bufferingCount = (_bufferingCount * _bufferingDecayFactor).floor(); + + if (newLimit != _currentBandwidthLimit) { + try { + await _platform.setBandwidthLimit(playerId, newLimit); + _currentBandwidthLimit = newLimit; + _lastQualityChange = _clock(); + } catch (e) { + // Silently ignore errors during auto-adjustment. + } + } + } + + /// Selects optimal bandwidth based on recent buffering frequency. + int _selectOptimalBandwidth() { + if (_bufferingCount > 5) { + return quality360p; + } + if (_bufferingCount > 3) { + return quality480p; + } + if (_bufferingCount > 1) { + return quality720p; + } + if (_bufferingCount > 0) { + return quality1080p; + } + return qualityUnlimited; + } + + /// Stops monitoring and releases resources. + void dispose() { + _isMonitoring = false; + _monitoringTimer?.cancel(); + _monitoringTimer = null; + } +} diff --git a/packages/video_player/video_player/lib/video_player.dart b/packages/video_player/video_player/lib/video_player.dart index 3e66109d7feb..b3d09f33bfec 100644 --- a/packages/video_player/video_player/lib/video_player.dart +++ b/packages/video_player/video_player/lib/video_player.dart @@ -13,6 +13,7 @@ import 'package:flutter/services.dart'; import 'package:video_player_platform_interface/video_player_platform_interface.dart' as platform_interface; +import 'src/adaptive_bitrate_manager.dart'; import 'src/closed_caption_file.dart'; export 'package:video_player_platform_interface/video_player_platform_interface.dart' @@ -524,6 +525,7 @@ class VideoPlayerController extends ValueNotifier { Completer? _creatingCompleter; StreamSubscription? _eventSubscription; _VideoAppLifeCycleObserver? _lifeCycleObserver; + AdaptiveBitrateManager? _adaptiveBitrateManager; /// The id of a player that hasn't been initialized. @visibleForTesting @@ -588,6 +590,17 @@ class VideoPlayerController extends ValueNotifier { (await _videoPlayerPlatform.createWithOptions(creationOptions)) ?? kUninitializedPlayerId; _creatingCompleter!.complete(null); + + // Enable adaptive bitrate management only for network streams + // (local files and assets don't use HLS/DASH adaptive streaming). + if (dataSourceType == platform_interface.DataSourceType.network) { + _adaptiveBitrateManager = AdaptiveBitrateManager( + playerId: _playerId, + platform: _videoPlayerPlatform, + ); + await _adaptiveBitrateManager!.startAutoAdaptiveQuality(); + } + final initializingCompleter = Completer(); // Apply the web-specific options @@ -638,6 +651,7 @@ class VideoPlayerController extends ValueNotifier { value = value.copyWith(buffered: event.buffered); case platform_interface.VideoEventType.bufferingStart: value = value.copyWith(isBuffering: true); + _adaptiveBitrateManager?.recordBufferingEvent(); case platform_interface.VideoEventType.bufferingEnd: value = value.copyWith(isBuffering: false); case platform_interface.VideoEventType.isPlayingStateUpdate: @@ -684,6 +698,7 @@ class VideoPlayerController extends ValueNotifier { if (!_isDisposed) { _isDisposed = true; _timer?.cancel(); + _adaptiveBitrateManager?.dispose(); await _eventSubscription?.cancel(); await _videoPlayerPlatform.dispose(_playerId); } @@ -850,6 +865,44 @@ class VideoPlayerController extends ValueNotifier { 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 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. diff --git a/packages/video_player/video_player/pubspec.yaml b/packages/video_player/video_player/pubspec.yaml index c55f4e823c4e..cdb05e028896 100644 --- a/packages/video_player/video_player/pubspec.yaml +++ b/packages/video_player/video_player/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for displaying inline video with other Flutter widgets on Android, iOS, macOS and web. repository: https://github.com/flutter/packages/tree/main/packages/video_player/video_player issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+video_player%22 -version: 2.11.1 +version: 2.12.0 environment: sdk: ^3.10.0 @@ -26,12 +26,13 @@ dependencies: flutter: sdk: flutter html: ^0.15.0 - video_player_android: ^2.9.1 - video_player_avfoundation: ^2.9.0 - video_player_platform_interface: ^6.6.0 + video_player_android: ^2.10.0 + video_player_avfoundation: ^2.10.0 + video_player_platform_interface: ^6.7.0 video_player_web: ^2.1.0 dev_dependencies: + fake_async: ^1.3.0 flutter_test: sdk: flutter leak_tracker_flutter_testing: any diff --git a/packages/video_player/video_player/test/adaptive_bitrate_manager_test.dart b/packages/video_player/video_player/test/adaptive_bitrate_manager_test.dart new file mode 100644 index 000000000000..2a2c941eba3f --- /dev/null +++ b/packages/video_player/video_player/test/adaptive_bitrate_manager_test.dart @@ -0,0 +1,402 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:fake_async/fake_async.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:video_player/src/adaptive_bitrate_manager.dart'; +import 'package:video_player_platform_interface/video_player_platform_interface.dart'; + +void main() { + group('AdaptiveBitrateManager', () { + late _FakePlatform platform; + const playerId = 42; + + setUp(() { + platform = _FakePlatform(); + }); + + AdaptiveBitrateManager createManager(FakeAsync fakeAsync) { + return AdaptiveBitrateManager( + playerId: playerId, + platform: platform, + clock: () => fakeAsync.getClock(DateTime(2024)).now(), + ); + } + + test('startAutoAdaptiveQuality sets unlimited bandwidth initially', () { + fakeAsync((FakeAsync async) { + final AdaptiveBitrateManager manager = createManager(async); + + manager.startAutoAdaptiveQuality(); + async.flushMicrotasks(); + + expect( + platform.bandwidthLimits[playerId], + AdaptiveBitrateManager.qualityUnlimited, + ); + expect(platform.setBandwidthCalls, 1); + + manager.dispose(); + }); + }); + + test('startAutoAdaptiveQuality is idempotent', () { + fakeAsync((FakeAsync async) { + final AdaptiveBitrateManager manager = createManager(async); + + manager.startAutoAdaptiveQuality(); + async.flushMicrotasks(); + manager.startAutoAdaptiveQuality(); + async.flushMicrotasks(); + + // Only one setBandwidthLimit call — second start is a no-op. + expect(platform.setBandwidthCalls, 1); + + manager.dispose(); + }); + }); + + test('recordBufferingEvent is ignored when not monitoring', () { + fakeAsync((FakeAsync async) { + final AdaptiveBitrateManager manager = createManager(async); + + // Record events before monitoring starts. + manager.recordBufferingEvent(); + manager.recordBufferingEvent(); + + // Start monitoring and wait for analysis. + manager.startAutoAdaptiveQuality(); + async.flushMicrotasks(); + async.elapse(const Duration(seconds: 10)); + async.flushMicrotasks(); + + // Only the initial unlimited call — no step-down since events were + // recorded before monitoring started. + expect( + platform.bandwidthLimits[playerId], + AdaptiveBitrateManager.qualityUnlimited, + ); + + manager.dispose(); + }); + }); + + test('single buffering event steps down to 1080p', () { + fakeAsync((FakeAsync async) { + final AdaptiveBitrateManager manager = createManager(async); + + manager.startAutoAdaptiveQuality(); + async.flushMicrotasks(); + + // Advance past cooldown. + async.elapse(const Duration(seconds: 6)); + async.flushMicrotasks(); + + // Record one buffering event. + manager.recordBufferingEvent(); + + // Advance to next monitoring tick (3 seconds). + async.elapse(const Duration(seconds: 3)); + async.flushMicrotasks(); + + expect( + platform.bandwidthLimits[playerId], + AdaptiveBitrateManager.quality1080p, + ); + + manager.dispose(); + }); + }); + + test('two buffering events step down to 720p', () { + fakeAsync((FakeAsync async) { + final AdaptiveBitrateManager manager = createManager(async); + + manager.startAutoAdaptiveQuality(); + async.flushMicrotasks(); + + async.elapse(const Duration(seconds: 6)); + async.flushMicrotasks(); + + manager.recordBufferingEvent(); + manager.recordBufferingEvent(); + + async.elapse(const Duration(seconds: 3)); + async.flushMicrotasks(); + + expect( + platform.bandwidthLimits[playerId], + AdaptiveBitrateManager.quality720p, + ); + + manager.dispose(); + }); + }); + + test('four buffering events step down to 480p', () { + fakeAsync((FakeAsync async) { + final AdaptiveBitrateManager manager = createManager(async); + + manager.startAutoAdaptiveQuality(); + async.flushMicrotasks(); + + async.elapse(const Duration(seconds: 6)); + async.flushMicrotasks(); + + for (var i = 0; i < 4; i++) { + manager.recordBufferingEvent(); + } + + async.elapse(const Duration(seconds: 3)); + async.flushMicrotasks(); + + expect( + platform.bandwidthLimits[playerId], + AdaptiveBitrateManager.quality480p, + ); + + manager.dispose(); + }); + }); + + test('six buffering events step down to 360p', () { + fakeAsync((FakeAsync async) { + final AdaptiveBitrateManager manager = createManager(async); + + manager.startAutoAdaptiveQuality(); + async.flushMicrotasks(); + + async.elapse(const Duration(seconds: 6)); + async.flushMicrotasks(); + + for (var i = 0; i < 6; i++) { + manager.recordBufferingEvent(); + } + + async.elapse(const Duration(seconds: 3)); + async.flushMicrotasks(); + + expect( + platform.bandwidthLimits[playerId], + AdaptiveBitrateManager.quality360p, + ); + + manager.dispose(); + }); + }); + + test('cooldown prevents rapid quality changes', () { + fakeAsync((FakeAsync async) { + final AdaptiveBitrateManager manager = createManager(async); + + manager.startAutoAdaptiveQuality(); + async.flushMicrotasks(); + + // Advance past initial cooldown and record events. + async.elapse(const Duration(seconds: 6)); + async.flushMicrotasks(); + + manager.recordBufferingEvent(); + + // First analysis fires → steps down to 1080p. + async.elapse(const Duration(seconds: 3)); + async.flushMicrotasks(); + expect( + platform.bandwidthLimits[playerId], + AdaptiveBitrateManager.quality1080p, + ); + final int callsAfterFirstChange = platform.setBandwidthCalls; + + // Record more events. + manager.recordBufferingEvent(); + manager.recordBufferingEvent(); + + // Next tick is within cooldown — should NOT change bandwidth. + async.elapse(const Duration(seconds: 3)); + async.flushMicrotasks(); + expect(platform.setBandwidthCalls, callsAfterFirstChange); + + manager.dispose(); + }); + }); + + test('quality recovers after buffering decays', () { + fakeAsync((FakeAsync async) { + final AdaptiveBitrateManager manager = createManager(async); + + manager.startAutoAdaptiveQuality(); + async.flushMicrotasks(); + + // Advance past cooldown. + async.elapse(const Duration(seconds: 6)); + async.flushMicrotasks(); + + // Cause heavy buffering → 360p. + for (var i = 0; i < 6; i++) { + manager.recordBufferingEvent(); + } + async.elapse(const Duration(seconds: 3)); + async.flushMicrotasks(); + expect( + platform.bandwidthLimits[playerId], + AdaptiveBitrateManager.quality360p, + ); + + // Let decay run with no new events. Each cycle multiplies by 0.7: + // 6→4 (after first analysis above), then 4→2, then 2→1, then 1→0 + // We need multiple cycles past the cooldown for the quality to recover. + // Advance past cooldown (5s) + multiple monitoring intervals. + async.elapse(const Duration(seconds: 30)); + async.flushMicrotasks(); + + // After enough decay cycles with no new buffering events, + // the quality should have recovered toward unlimited. + final int? finalLimit = platform.bandwidthLimits[playerId]; + expect( + finalLimit == AdaptiveBitrateManager.qualityUnlimited || + finalLimit == AdaptiveBitrateManager.quality1080p || + finalLimit == AdaptiveBitrateManager.quality720p, + isTrue, + reason: + 'Quality should recover after buffering decays, ' + 'got limit: $finalLimit', + ); + + manager.dispose(); + }); + }); + + test('dispose stops the monitoring timer', () { + fakeAsync((FakeAsync async) { + final AdaptiveBitrateManager manager = createManager(async); + + manager.startAutoAdaptiveQuality(); + async.flushMicrotasks(); + final int callsBeforeDispose = platform.setBandwidthCalls; + + manager.dispose(); + + // Record events and advance time — nothing should fire. + manager.recordBufferingEvent(); + async.elapse(const Duration(seconds: 60)); + async.flushMicrotasks(); + + expect(platform.setBandwidthCalls, callsBeforeDispose); + }); + }); + + test('platform error during start prevents monitoring', () { + fakeAsync((FakeAsync async) { + platform.failOnSetBandwidth = true; + final AdaptiveBitrateManager manager = createManager(async); + + manager.startAutoAdaptiveQuality(); + async.flushMicrotasks(); + + // Record events and advance time — nothing should fire since + // monitoring failed to start. + manager.recordBufferingEvent(); + async.elapse(const Duration(seconds: 10)); + async.flushMicrotasks(); + + // Only the one failed call, no subsequent calls. + expect(platform.setBandwidthCalls, 1); + + manager.dispose(); + }); + }); + + test('platform error during adjustment is silently ignored', () { + fakeAsync((FakeAsync async) { + final AdaptiveBitrateManager manager = createManager(async); + + manager.startAutoAdaptiveQuality(); + async.flushMicrotasks(); + + // Advance past cooldown. + async.elapse(const Duration(seconds: 6)); + async.flushMicrotasks(); + + // Now make future calls fail. + platform.failOnSetBandwidth = true; + + manager.recordBufferingEvent(); + async.elapse(const Duration(seconds: 3)); + async.flushMicrotasks(); + + // Should not throw — error is silently caught. + // The bandwidth limit in the platform won't be updated since it threw, + // but monitoring continues. + manager.dispose(); + }); + }); + + test('quality thresholds are correct constants', () { + expect(AdaptiveBitrateManager.quality360p, 500000); + expect(AdaptiveBitrateManager.quality480p, 800000); + expect(AdaptiveBitrateManager.quality720p, 1200000); + expect(AdaptiveBitrateManager.quality1080p, 2500000); + expect(AdaptiveBitrateManager.qualityUnlimited, 0); + }); + }); +} + +/// Minimal fake platform that only implements [setBandwidthLimit]. +class _FakePlatform extends VideoPlayerPlatform { + final Map bandwidthLimits = {}; + int setBandwidthCalls = 0; + bool failOnSetBandwidth = false; + + @override + Future setBandwidthLimit(int playerId, int maxBandwidthBps) async { + setBandwidthCalls++; + if (failOnSetBandwidth) { + throw Exception('setBandwidthLimit failed'); + } + bandwidthLimits[playerId] = maxBandwidthBps; + } + + // -- Unused stubs required by VideoPlayerPlatform. -- + + @override + Future create(DataSource dataSource) async => 0; + + @override + Future dispose(int playerId) async {} + + @override + Future init() async {} + + @override + Stream videoEventsFor(int playerId) => + const Stream.empty(); + + @override + Future pause(int playerId) async {} + + @override + Future play(int playerId) async {} + + @override + Future getPosition(int playerId) async => Duration.zero; + + @override + Future seekTo(int playerId, Duration position) async {} + + @override + Future setLooping(int playerId, bool looping) async {} + + @override + Future setVolume(int playerId, double volume) async {} + + @override + Future setPlaybackSpeed(int playerId, double speed) async {} + + @override + Future setMixWithOthers(bool mixWithOthers) async {} + + @override + Widget buildView(int playerId) => const SizedBox.shrink(); +} diff --git a/packages/video_player/video_player/test/video_player_test.dart b/packages/video_player/video_player/test/video_player_test.dart index 77608a45d0ac..e1e78ae74686 100644 --- a/packages/video_player/video_player/test/video_player_test.dart +++ b/packages/video_player/video_player/test/video_player_test.dart @@ -130,6 +130,9 @@ class FakeController extends ValueNotifier return true; } + @override + Future setBandwidthLimit(int maxBandwidthBps) async {} + String? selectedAudioTrackId; } @@ -1106,6 +1109,34 @@ void main() { }); }); + group('setBandwidthLimit', () { + test('delegates to platform', () async { + final controller = VideoPlayerController.networkUrl(_localhostUri); + addTearDown(controller.dispose); + await controller.initialize(); + + await controller.setBandwidthLimit(5000000); + + expect(fakeVideoPlayerPlatform.calls, contains('setBandwidthLimit')); + expect( + fakeVideoPlayerPlatform.bandwidthLimits[controller.playerId], + 5000000, + ); + }); + + test('does nothing when not initialized', () async { + final controller = VideoPlayerController.networkUrl(_localhostUri); + addTearDown(controller.dispose); + + await controller.setBandwidthLimit(5000000); + + expect( + fakeVideoPlayerPlatform.calls, + isNot(contains('setBandwidthLimit')), + ); + }); + }); + group('caption', () { test('works when position updates', () async { final controller = VideoPlayerController.networkUrl( @@ -2289,4 +2320,18 @@ class FakeVideoPlayerPlatform extends VideoPlayerPlatform { } final Map selectedAudioTrackIds = {}; + + @override + Future setBandwidthLimit(int playerId, int maxBandwidthBps) async { + calls.add('setBandwidthLimit'); + bandwidthLimits[playerId] = maxBandwidthBps; + } + + @override + bool isBandwidthLimitSupportAvailable() { + calls.add('isBandwidthLimitSupportAvailable'); + return true; + } + + final Map bandwidthLimits = {}; }