diff --git a/packages/video_player/video_player/CHANGELOG.md b/packages/video_player/video_player/CHANGELOG.md
index 3524be91edc..5a6940d5132 100644
--- a/packages/video_player/video_player/CHANGELOG.md
+++ b/packages/video_player/video_player/CHANGELOG.md
@@ -1,10 +1,11 @@
-## NEXT
+## 2.11.0
* 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.
* Applications built with older versions of Flutter will continue to
use compatible versions of the platform implementations.
+* Adds video quality selection support for HLS/DASH adaptive streams via `getVideoTracks()`, `selectVideoTrack()`, and `isVideoTrackSupportAvailable()` methods. This enables programmatic switching between different resolution/bitrate variants.
## 2.10.1
diff --git a/packages/video_player/video_player/example/ios/Flutter/AppFrameworkInfo.plist b/packages/video_player/video_player/example/ios/Flutter/AppFrameworkInfo.plist
index 7c569640062..1dc6cf7652b 100644
--- a/packages/video_player/video_player/example/ios/Flutter/AppFrameworkInfo.plist
+++ b/packages/video_player/video_player/example/ios/Flutter/AppFrameworkInfo.plist
@@ -21,6 +21,6 @@
CFBundleVersion
1.0
MinimumOSVersion
- 12.0
+ 13.0
diff --git a/packages/video_player/video_player/example/ios/Podfile b/packages/video_player/video_player/example/ios/Podfile
index 01d4aa611bb..17adeb14132 100644
--- a/packages/video_player/video_player/example/ios/Podfile
+++ b/packages/video_player/video_player/example/ios/Podfile
@@ -1,5 +1,5 @@
# Uncomment this line to define a global platform for your project
-# platform :ios, '12.0'
+# platform :ios, '13.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
diff --git a/packages/video_player/video_player/example/ios/Runner.xcodeproj/project.pbxproj b/packages/video_player/video_player/example/ios/Runner.xcodeproj/project.pbxproj
index 2ab10fb9081..f6c041ca40b 100644
--- a/packages/video_player/video_player/example/ios/Runner.xcodeproj/project.pbxproj
+++ b/packages/video_player/video_player/example/ios/Runner.xcodeproj/project.pbxproj
@@ -140,6 +140,7 @@
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
+ 32E9BFFE171C16A1A344FF4F /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
@@ -205,6 +206,23 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
+ 32E9BFFE171C16A1A344FF4F /* [CP] Embed Pods Frameworks */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
+ );
+ name = "[CP] Embed Pods Frameworks";
+ outputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
+ showEnvVarsInLog = 0;
+ };
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
@@ -335,7 +353,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 12.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@@ -414,7 +432,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 12.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -465,7 +483,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 12.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
diff --git a/packages/video_player/video_player/example/lib/main.dart b/packages/video_player/video_player/example/lib/main.dart
index d47a5abc601..27557ada553 100644
--- a/packages/video_player/video_player/example/lib/main.dart
+++ b/packages/video_player/video_player/example/lib/main.dart
@@ -10,6 +10,7 @@ library;
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
+import 'video_tracks_demo.dart';
void main() {
runApp(MaterialApp(home: _App()));
@@ -25,6 +26,19 @@ class _App extends StatelessWidget {
appBar: AppBar(
title: const Text('Video player example'),
actions: [
+ IconButton(
+ key: const ValueKey('video_tracks_demo'),
+ icon: const Icon(Icons.high_quality),
+ tooltip: 'Video Tracks Demo',
+ onPressed: () {
+ Navigator.push(
+ context,
+ MaterialPageRoute(
+ builder: (BuildContext context) => const VideoTracksDemo(),
+ ),
+ );
+ },
+ ),
IconButton(
key: const ValueKey('push_tab'),
icon: const Icon(Icons.navigation),
diff --git a/packages/video_player/video_player/example/lib/video_tracks_demo.dart b/packages/video_player/video_player/example/lib/video_tracks_demo.dart
new file mode 100644
index 00000000000..a1b2f5f76e2
--- /dev/null
+++ b/packages/video_player/video_player/example/lib/video_tracks_demo.dart
@@ -0,0 +1,460 @@
+// Copyright 2013 The Flutter Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'package:flutter/material.dart';
+import 'package:video_player/video_player.dart';
+
+/// A demo page that showcases video track (quality) selection functionality.
+class VideoTracksDemo extends StatefulWidget {
+ /// Creates a VideoTracksDemo widget.
+ const VideoTracksDemo({super.key});
+
+ @override
+ State createState() => _VideoTracksDemoState();
+}
+
+class _VideoTracksDemoState extends State {
+ VideoPlayerController? _controller;
+ List _videoTracks = [];
+ bool _isLoading = false;
+ String? _error;
+ bool _isAutoQuality = true;
+
+ // Track previous state to detect relevant changes
+ bool _wasPlaying = false;
+ bool _wasInitialized = false;
+
+ // Sample video URLs with multiple video tracks (HLS streams)
+ static const List _sampleVideos = [
+ 'https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_16x9/bipbop_16x9_variant.m3u8',
+ 'https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_fmp4/master.m3u8',
+ 'https://flutter.github.io/assets-for-api-docs/assets/videos/butterfly.mp4',
+ ];
+
+ int _selectedVideoIndex = 0;
+
+ @override
+ void initState() {
+ super.initState();
+ _initializeVideo();
+ }
+
+ Future _initializeVideo() async {
+ setState(() {
+ _isLoading = true;
+ _error = null;
+ _isAutoQuality = true;
+ });
+
+ try {
+ await _controller?.dispose();
+
+ final controller = VideoPlayerController.networkUrl(
+ Uri.parse(_sampleVideos[_selectedVideoIndex]),
+ );
+ _controller = controller;
+
+ await controller.initialize();
+
+ // Add listener for video player state changes
+ _controller!.addListener(_onVideoPlayerValueChanged);
+
+ // Initialize tracking variables
+ _wasPlaying = _controller!.value.isPlaying;
+ _wasInitialized = _controller!.value.isInitialized;
+
+ // Get video tracks after initialization
+ await _loadVideoTracks();
+ if (!mounted) {
+ return;
+ }
+ setState(() {
+ _isLoading = false;
+ });
+ } catch (e) {
+ if (!mounted) {
+ return;
+ }
+ setState(() {
+ _error = 'Failed to initialize video: $e';
+ _isLoading = false;
+ });
+ }
+ }
+
+ Future _loadVideoTracks() async {
+ final VideoPlayerController? controller = _controller;
+ if (controller == null || !controller.value.isInitialized) {
+ return;
+ }
+
+ // Check if video track selection is supported
+ if (!controller.isVideoTrackSupportAvailable()) {
+ if (!mounted) {
+ return;
+ }
+ setState(() {
+ _error = 'Video track selection is not supported on this platform.';
+ _videoTracks = [];
+ });
+ return;
+ }
+
+ try {
+ final List tracks = await controller.getVideoTracks();
+ if (!mounted) {
+ return;
+ }
+ setState(() {
+ _videoTracks = tracks;
+ });
+ } catch (e) {
+ if (!mounted) {
+ return;
+ }
+ setState(() {
+ _error = 'Failed to load video tracks: $e';
+ });
+ }
+ }
+
+ Future _selectVideoTrack(VideoTrack? track) async {
+ final VideoPlayerController? controller = _controller;
+ if (controller == null) {
+ return;
+ }
+
+ final ScaffoldMessengerState scaffoldMessenger = ScaffoldMessenger.of(context);
+
+ try {
+ await controller.selectVideoTrack(track);
+
+ setState(() {
+ _isAutoQuality = track == null;
+ });
+
+ // Reload tracks to update selection status
+ await _loadVideoTracks();
+
+ if (!mounted) {
+ return;
+ }
+ final message = track == null
+ ? 'Switched to automatic quality'
+ : 'Selected video track: ${_getTrackLabel(track)}';
+ scaffoldMessenger.showSnackBar(SnackBar(content: Text(message)));
+ } catch (e) {
+ if (!mounted) {
+ return;
+ }
+ scaffoldMessenger.showSnackBar(
+ SnackBar(content: Text('Failed to select video track: $e')),
+ );
+ }
+ }
+
+ String _getTrackLabel(VideoTrack track) {
+ if (track.label.isNotEmpty) {
+ return track.label;
+ }
+ if (track.height != null && track.width != null) {
+ return '${track.width}x${track.height}';
+ }
+ if (track.height != null) {
+ return '${track.height}p';
+ }
+ return 'Track ${track.id}';
+ }
+
+ String _formatBitrate(int? bitrate) {
+ if (bitrate == null) {
+ return 'Unknown';
+ }
+ if (bitrate >= 1000000) {
+ return '${(bitrate / 1000000).toStringAsFixed(2)} Mbps';
+ }
+ if (bitrate >= 1000) {
+ return '${(bitrate / 1000).toStringAsFixed(0)} Kbps';
+ }
+ return '$bitrate bps';
+ }
+
+ void _onVideoPlayerValueChanged() {
+ if (!mounted || _controller == null) {
+ return;
+ }
+
+ final VideoPlayerValue currentValue = _controller!.value;
+ var shouldUpdate = false;
+
+ // Check for relevant state changes that affect UI
+ if (currentValue.isPlaying != _wasPlaying) {
+ _wasPlaying = currentValue.isPlaying;
+ shouldUpdate = true;
+ }
+
+ if (currentValue.isInitialized != _wasInitialized) {
+ _wasInitialized = currentValue.isInitialized;
+ shouldUpdate = true;
+ }
+
+ // Only call setState if there are relevant changes
+ if (shouldUpdate) {
+ setState(() {});
+ }
+ }
+
+ @override
+ void dispose() {
+ _controller?.removeListener(_onVideoPlayerValueChanged);
+ _controller?.dispose();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(
+ title: const Text('Video Tracks Demo'),
+ backgroundColor: Theme.of(context).colorScheme.inversePrimary,
+ ),
+ body: Column(
+ children: [
+ // Video selection dropdown
+ Padding(
+ padding: const EdgeInsets.all(16.0),
+ child: DropdownMenu(
+ initialSelection: _selectedVideoIndex,
+ label: const Text('Select Video'),
+ inputDecorationTheme: const InputDecorationTheme(
+ border: OutlineInputBorder(),
+ ),
+ dropdownMenuEntries: _sampleVideos.indexed.map(((int, String) record) {
+ final (index, url) = record;
+ final label = url.contains('.m3u8')
+ ? 'HLS Stream ${index + 1}'
+ : 'MP4 Video ${index + 1}';
+ return DropdownMenuEntry(value: index, label: label);
+ }).toList(),
+ onSelected: (int? value) {
+ if (value != null && value != _selectedVideoIndex) {
+ setState(() {
+ _selectedVideoIndex = value;
+ });
+ _initializeVideo();
+ }
+ },
+ ),
+ ),
+
+ // Video player
+ Expanded(
+ flex: 2,
+ child: ColoredBox(color: Colors.black, child: _buildVideoPlayer()),
+ ),
+
+ // Video tracks list
+ Expanded(flex: 3, child: _buildVideoTracksList()),
+ ],
+ ),
+ floatingActionButton: FloatingActionButton(
+ onPressed: _loadVideoTracks,
+ tooltip: 'Refresh Video Tracks',
+ child: const Icon(Icons.refresh),
+ ),
+ );
+ }
+
+ Widget _buildVideoPlayer() {
+ if (_isLoading) {
+ return const Center(child: CircularProgressIndicator());
+ }
+
+ if (_error != null && _controller?.value.isInitialized != true) {
+ return Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(Icons.error, size: 48, color: Colors.red[300]),
+ const SizedBox(height: 16),
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16.0),
+ child: Text(
+ _error!,
+ style: const TextStyle(color: Colors.white),
+ textAlign: TextAlign.center,
+ ),
+ ),
+ const SizedBox(height: 16),
+ ElevatedButton(onPressed: _initializeVideo, child: const Text('Retry')),
+ ],
+ ),
+ );
+ }
+
+ final VideoPlayerController? controller = _controller;
+ if (controller?.value.isInitialized ?? false) {
+ return Stack(
+ alignment: Alignment.center,
+ children: [
+ AspectRatio(
+ aspectRatio: controller!.value.aspectRatio,
+ child: VideoPlayer(controller),
+ ),
+ _buildPlayPauseButton(),
+ Positioned(
+ bottom: 0,
+ left: 0,
+ right: 0,
+ child: VideoProgressIndicator(controller, allowScrubbing: true),
+ ),
+ ],
+ );
+ }
+
+ return const Center(
+ child: Text('No video loaded', style: TextStyle(color: Colors.white)),
+ );
+ }
+
+ Widget _buildPlayPauseButton() {
+ final VideoPlayerController? controller = _controller;
+ if (controller == null) {
+ return const SizedBox.shrink();
+ }
+
+ return Container(
+ decoration: BoxDecoration(
+ color: Colors.black54,
+ borderRadius: BorderRadius.circular(30),
+ ),
+ child: IconButton(
+ iconSize: 48,
+ color: Colors.white,
+ onPressed: () {
+ if (controller.value.isPlaying) {
+ controller.pause();
+ } else {
+ controller.play();
+ }
+ },
+ icon: Icon(controller.value.isPlaying ? Icons.pause : Icons.play_arrow),
+ ),
+ );
+ }
+
+ Widget _buildVideoTracksList() {
+ return Container(
+ padding: const EdgeInsets.all(16.0),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Row(
+ children: [
+ const Icon(Icons.high_quality),
+ const SizedBox(width: 8),
+ Text(
+ 'Video Tracks (${_videoTracks.length})',
+ style: Theme.of(context).textTheme.headlineSmall,
+ ),
+ ],
+ ),
+ const SizedBox(height: 8),
+
+ // Auto quality option
+ Card(
+ margin: const EdgeInsets.only(bottom: 8.0),
+ child: ListTile(
+ leading: CircleAvatar(
+ backgroundColor: _isAutoQuality ? Colors.blue : Colors.grey,
+ child: Icon(
+ _isAutoQuality ? Icons.check : Icons.auto_awesome,
+ color: Colors.white,
+ ),
+ ),
+ title: Text(
+ 'Automatic Quality',
+ style: TextStyle(
+ fontWeight: _isAutoQuality ? FontWeight.bold : FontWeight.normal,
+ ),
+ ),
+ subtitle: const Text('Let the player choose the best quality'),
+ trailing: _isAutoQuality
+ ? const Icon(Icons.radio_button_checked, color: Colors.blue)
+ : const Icon(Icons.radio_button_unchecked),
+ onTap: _isAutoQuality ? null : () => _selectVideoTrack(null),
+ ),
+ ),
+
+ const SizedBox(height: 8),
+
+ if (_videoTracks.isEmpty && _error == null)
+ const Expanded(
+ child: Center(
+ child: Text(
+ 'No video tracks available.\nTry loading an HLS stream with multiple quality levels.',
+ textAlign: TextAlign.center,
+ style: TextStyle(fontSize: 16, color: Colors.grey),
+ ),
+ ),
+ )
+ else if (_error != null && (_controller?.value.isInitialized ?? false))
+ Expanded(
+ child: Center(
+ child: Text(
+ _error!,
+ textAlign: TextAlign.center,
+ style: const TextStyle(fontSize: 16, color: Colors.orange),
+ ),
+ ),
+ )
+ else
+ Expanded(
+ child: ListView.builder(
+ itemCount: _videoTracks.length,
+ itemBuilder: (BuildContext context, int index) {
+ final VideoTrack track = _videoTracks[index];
+ return _buildVideoTrackTile(track);
+ },
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildVideoTrackTile(VideoTrack track) {
+ final bool isSelected = track.isSelected && !_isAutoQuality;
+
+ return Card(
+ margin: const EdgeInsets.only(bottom: 8.0),
+ child: ListTile(
+ leading: CircleAvatar(
+ backgroundColor: isSelected ? Colors.green : Colors.grey,
+ child: Icon(isSelected ? Icons.check : Icons.hd, color: Colors.white),
+ ),
+ title: Text(
+ _getTrackLabel(track),
+ style: TextStyle(fontWeight: isSelected ? FontWeight.bold : FontWeight.normal),
+ ),
+ subtitle: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text('ID: ${track.id}'),
+ if (track.width != null && track.height != null)
+ Text('Resolution: ${track.width}x${track.height}'),
+ Text('Bitrate: ${_formatBitrate(track.bitrate)}'),
+ if (track.frameRate != null)
+ Text('Frame Rate: ${track.frameRate!.toStringAsFixed(2)} fps'),
+ if (track.codec != null) Text('Codec: ${track.codec}'),
+ ],
+ ),
+ trailing: isSelected
+ ? const Icon(Icons.radio_button_checked, color: Colors.green)
+ : const Icon(Icons.radio_button_unchecked),
+ onTap: isSelected ? null : () => _selectVideoTrack(track),
+ ),
+ );
+ }
+}
diff --git a/packages/video_player/video_player/example/macos/Podfile b/packages/video_player/video_player/example/macos/Podfile
index ae77cc1d426..66f6172bbb3 100644
--- a/packages/video_player/video_player/example/macos/Podfile
+++ b/packages/video_player/video_player/example/macos/Podfile
@@ -1,4 +1,4 @@
-platform :osx, '10.14'
+platform :osx, '10.15'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
diff --git a/packages/video_player/video_player/example/macos/Runner.xcodeproj/project.pbxproj b/packages/video_player/video_player/example/macos/Runner.xcodeproj/project.pbxproj
index e6fa40d2ed6..91fff87add8 100644
--- a/packages/video_player/video_player/example/macos/Runner.xcodeproj/project.pbxproj
+++ b/packages/video_player/video_player/example/macos/Runner.xcodeproj/project.pbxproj
@@ -193,6 +193,7 @@
33CC10EB2044A3C60003C045 /* Resources */,
33CC110E2044A8840003C045 /* Bundle Framework */,
3399D490228B24CF009A79C7 /* ShellScript */,
+ CC40D77B687270FD3E1BD701 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
@@ -306,6 +307,23 @@
shellPath = /bin/sh;
shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire";
};
+ CC40D77B687270FD3E1BD701 /* [CP] Embed Pods Frameworks */ = {
+ isa = PBXShellScriptBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
+ );
+ name = "[CP] Embed Pods Frameworks";
+ outputFileListPaths = (
+ "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
+ showEnvVarsInLog = 0;
+ };
D3E396DFBCC51886820113AA /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
@@ -402,7 +420,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- MACOSX_DEPLOYMENT_TARGET = 10.14;
+ MACOSX_DEPLOYMENT_TARGET = 10.15;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
@@ -481,7 +499,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- MACOSX_DEPLOYMENT_TARGET = 10.14;
+ MACOSX_DEPLOYMENT_TARGET = 10.15;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
@@ -528,7 +546,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- MACOSX_DEPLOYMENT_TARGET = 10.14;
+ MACOSX_DEPLOYMENT_TARGET = 10.15;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = macosx;
SWIFT_COMPILATION_MODE = wholemodule;
diff --git a/packages/video_player/video_player/lib/video_player.dart b/packages/video_player/video_player/lib/video_player.dart
index f0589cb4686..2a2ee098c2b 100644
--- a/packages/video_player/video_player/lib/video_player.dart
+++ b/packages/video_player/video_player/lib/video_player.dart
@@ -10,6 +10,9 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:video_player_platform_interface/video_player_platform_interface.dart';
+// Import platform VideoTrack for internal conversion
+import 'package:video_player_platform_interface/video_player_platform_interface.dart'
+ as platform_interface;
import 'src/closed_caption_file.dart';
@@ -820,6 +823,74 @@ class VideoPlayerController extends ValueNotifier {
}
bool get _isDisposedOrNotInitialized => _isDisposed || !value.isInitialized;
+
+ /// Gets the available video tracks (quality variants) for the video.
+ ///
+ /// Returns a list of [VideoTrack] objects representing the available
+ /// video quality variants. For HLS/DASH adaptive streams, this returns the
+ /// different quality levels (resolution/bitrate variants) available.
+ /// For non-adaptive videos (MP4, MOV, etc.), this returns an empty list
+ /// as they have a single fixed quality that cannot be switched.
+ ///
+ /// Note: On iOS 13-14, this returns an empty list as the AVAssetVariant API
+ /// requires iOS 15+. On web, this throws an [UnimplementedError].
+ ///
+ /// Check [isVideoTrackSupportAvailable] before calling this method to ensure
+ /// the platform supports video track selection.
+ Future> getVideoTracks() async {
+ if (_isDisposedOrNotInitialized) {
+ return [];
+ }
+ final List platformTracks =
+ await _videoPlayerPlatform.getVideoTracks(_playerId);
+ return platformTracks
+ .map(
+ (platform_interface.VideoTrack track) =>
+ VideoTrack._fromPlatform(track),
+ )
+ .toList();
+ }
+
+ /// Selects which video track (quality variant) is chosen for playback.
+ ///
+ /// Pass a [VideoTrack] to select a specific quality.
+ /// Pass `null` to enable automatic quality selection (adaptive streaming).
+ ///
+ /// On iOS, this sets `preferredPeakBitRate` on the AVPlayerItem.
+ /// On Android, this uses ExoPlayer's track selection override.
+ /// On web, this throws an [UnimplementedError].
+ ///
+ /// Check [isVideoTrackSupportAvailable] before calling this method to ensure
+ /// the platform supports video track selection.
+ Future selectVideoTrack(VideoTrack? track) async {
+ if (_isDisposedOrNotInitialized) {
+ return;
+ }
+ // Convert app-facing VideoTrack to platform interface VideoTrack
+ final platform_interface.VideoTrack? platformTrack = track != null
+ ? platform_interface.VideoTrack(
+ id: track.id,
+ isSelected: track.isSelected,
+ label: track.label.isEmpty ? null : track.label,
+ bitrate: track.bitrate,
+ width: track.width,
+ height: track.height,
+ frameRate: track.frameRate,
+ codec: track.codec,
+ )
+ : null;
+ await _videoPlayerPlatform.selectVideoTrack(_playerId, platformTrack);
+ }
+
+ /// Returns whether video track selection is supported on this platform.
+ ///
+ /// Returns `true` on Android and iOS, `false` on web.
+ ///
+ /// Use this to check before calling [getVideoTracks] or [selectVideoTrack]
+ /// to avoid [UnimplementedError] exceptions on unsupported platforms.
+ bool isVideoTrackSupportAvailable() {
+ return _videoPlayerPlatform.isVideoTrackSupportAvailable();
+ }
}
class _VideoAppLifeCycleObserver extends Object with WidgetsBindingObserver {
@@ -1222,3 +1293,116 @@ class ClosedCaption extends StatelessWidget {
);
}
}
+
+/// Represents a video track (quality variant) in a video with its metadata.
+///
+/// For HLS/DASH adaptive streams, each [VideoTrack] represents a different
+/// quality level (e.g., 1080p, 720p, 480p). For non-adaptive videos (MP4,
+/// MOV, etc.), no tracks are returned as they have a single fixed quality.
+@immutable
+class VideoTrack {
+ /// Constructs an instance of [VideoTrack].
+ const VideoTrack({
+ required this.id,
+ required this.isSelected,
+ this.label = '',
+ this.bitrate,
+ this.width,
+ this.height,
+ this.frameRate,
+ this.codec,
+ });
+
+ /// Creates a [VideoTrack] from a platform interface [VideoTrack].
+ factory VideoTrack._fromPlatform(platform_interface.VideoTrack track) {
+ return VideoTrack(
+ id: track.id,
+ isSelected: track.isSelected,
+ label: track.label ?? '',
+ bitrate: track.bitrate,
+ width: track.width,
+ height: track.height,
+ frameRate: track.frameRate,
+ codec: track.codec,
+ );
+ }
+
+ /// Unique identifier for the video track.
+ ///
+ /// The format is platform-specific:
+ /// - Android: `"{groupIndex}_{trackIndex}"` (e.g., `"0_2"`)
+ /// - iOS: `"variant_{bitrate}"` for HLS adaptive streams
+ final String id;
+
+ /// Whether this track is currently selected.
+ final bool isSelected;
+
+ /// Human-readable label for the track (e.g., "1080p", "720p").
+ ///
+ /// Defaults to an empty string if not available from the platform.
+ final String label;
+
+ /// Bitrate of the video track in bits per second.
+ ///
+ /// May be null if not available from the platform.
+ final int? bitrate;
+
+ /// Video width in pixels.
+ ///
+ /// May be null if not available from the platform.
+ final int? width;
+
+ /// Video height in pixels.
+ ///
+ /// May be null if not available from the platform.
+ final int? height;
+
+ /// Frame rate in frames per second.
+ ///
+ /// May be null if not available from the platform.
+ final double? frameRate;
+
+ /// Video codec used (e.g., "avc1", "hevc", "vp9").
+ ///
+ /// May be null if not available from the platform.
+ final String? codec;
+
+ @override
+ bool operator ==(Object other) {
+ return identical(this, other) ||
+ other is VideoTrack &&
+ runtimeType == other.runtimeType &&
+ id == other.id &&
+ isSelected == other.isSelected &&
+ label == other.label &&
+ bitrate == other.bitrate &&
+ width == other.width &&
+ height == other.height &&
+ frameRate == other.frameRate &&
+ codec == other.codec;
+ }
+
+ @override
+ int get hashCode => Object.hash(
+ id,
+ isSelected,
+ label,
+ bitrate,
+ width,
+ height,
+ frameRate,
+ codec,
+ );
+
+ @override
+ String toString() =>
+ 'VideoTrack('
+ 'id: $id, '
+ 'isSelected: $isSelected, '
+ 'label: $label, '
+ 'bitrate: $bitrate, '
+ 'width: $width, '
+ 'height: $height, '
+ 'frameRate: $frameRate, '
+ 'codec: $codec)';
+}
diff --git a/packages/video_player/video_player/pubspec.yaml b/packages/video_player/video_player/pubspec.yaml
index a71e4532339..9b1bef5025d 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.10.1
+version: 2.11.0
environment:
sdk: ^3.8.0
@@ -25,10 +25,14 @@ 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_web: ^2.1.0
+ video_player_android:
+ path: ../video_player_android
+ video_player_avfoundation:
+ path: ../video_player_avfoundation
+ video_player_platform_interface:
+ path: ../video_player_platform_interface
+ video_player_web:
+ path: ../video_player_web
dev_dependencies:
flutter_test:
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 ea565bd9073..7f4d242bf8b 100644
--- a/packages/video_player/video_player/test/video_player_test.dart
+++ b/packages/video_player/video_player/test/video_player_test.dart
@@ -10,7 +10,11 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:video_player/video_player.dart';
-import 'package:video_player_platform_interface/video_player_platform_interface.dart';
+import 'package:video_player_platform_interface/video_player_platform_interface.dart'
+ hide VideoTrack;
+import 'package:video_player_platform_interface/video_player_platform_interface.dart'
+ as platform_interface
+ show VideoTrack;
const String _localhost = 'https://127.0.0.1';
final Uri _localhostUri = Uri.parse(_localhost);
@@ -84,6 +88,15 @@ class FakeController extends ValueNotifier
Future setClosedCaptionFile(
Future? closedCaptionFile,
) async {}
+
+ @override
+ Future> getVideoTracks() async => [];
+
+ @override
+ Future selectVideoTrack(VideoTrack? track) async {}
+
+ @override
+ bool isVideoTrackSupportAvailable() => false;
}
Future _loadClosedCaption() async =>
@@ -1486,6 +1499,96 @@ void main() {
await controller.seekTo(const Duration(seconds: 20));
});
+
+ group('video tracks', () {
+ test('isVideoTrackSupportAvailable returns platform value', () async {
+ final controller = VideoPlayerController.networkUrl(_localhostUri);
+ await controller.initialize();
+
+ expect(controller.isVideoTrackSupportAvailable(), true);
+ });
+
+ test('getVideoTracks returns empty list when not initialized', () async {
+ final controller = VideoPlayerController.networkUrl(_localhostUri);
+
+ final List tracks = await controller.getVideoTracks();
+
+ expect(tracks, isEmpty);
+ });
+
+ test('getVideoTracks returns tracks from platform', () async {
+ final controller = VideoPlayerController.networkUrl(_localhostUri);
+ await controller.initialize();
+
+ fakeVideoPlayerPlatform.setVideoTracksForPlayer(
+ controller.playerId,
+ [
+ const platform_interface.VideoTrack(
+ id: '0_0',
+ isSelected: true,
+ label: '1080p',
+ bitrate: 5000000,
+ width: 1920,
+ height: 1080,
+ ),
+ const platform_interface.VideoTrack(
+ id: '0_1',
+ isSelected: false,
+ label: '720p',
+ bitrate: 2500000,
+ width: 1280,
+ height: 720,
+ ),
+ ],
+ );
+
+ final List tracks = await controller.getVideoTracks();
+
+ expect(tracks.length, 2);
+ expect(tracks[0].id, '0_0');
+ expect(tracks[0].label, '1080p');
+ expect(tracks[0].isSelected, true);
+ expect(tracks[0].bitrate, 5000000);
+ expect(tracks[1].id, '0_1');
+ expect(tracks[1].label, '720p');
+ expect(fakeVideoPlayerPlatform.calls, contains('getVideoTracks'));
+ });
+
+ test('selectVideoTrack calls platform with track', () async {
+ final controller = VideoPlayerController.networkUrl(_localhostUri);
+ await controller.initialize();
+
+ const track = VideoTrack(
+ id: '0_1',
+ isSelected: false,
+ label: '720p',
+ bitrate: 2500000,
+ );
+ await controller.selectVideoTrack(track);
+
+ expect(fakeVideoPlayerPlatform.calls, contains('selectVideoTrack'));
+ });
+
+ test('selectVideoTrack with null enables auto quality', () async {
+ final controller = VideoPlayerController.networkUrl(_localhostUri);
+ await controller.initialize();
+
+ await controller.selectVideoTrack(null);
+
+ expect(fakeVideoPlayerPlatform.calls, contains('selectVideoTrack'));
+ });
+
+ test('selectVideoTrack does nothing when not initialized', () async {
+ final controller = VideoPlayerController.networkUrl(_localhostUri);
+
+ await controller.selectVideoTrack(null);
+
+ expect(
+ fakeVideoPlayerPlatform.calls,
+ isNot(contains('selectVideoTrack')),
+ );
+ });
+ });
}
class FakeVideoPlayerPlatform extends VideoPlayerPlatform {
@@ -1626,4 +1729,35 @@ class FakeVideoPlayerPlatform extends VideoPlayerPlatform {
calls.add('setWebOptions');
webOptions[playerId] = options;
}
+
+ // Video track selection support
+ final Map> _videoTracks =
+ >{};
+ void setVideoTracksForPlayer(
+ int playerId,
+ List tracks,
+ ) {
+ _videoTracks[playerId] = tracks;
+ }
+
+ @override
+ Future> getVideoTracks(
+ int playerId,
+ ) async {
+ calls.add('getVideoTracks');
+ return _videoTracks[playerId] ?? [];
+ }
+
+ @override
+ Future selectVideoTrack(
+ int playerId,
+ platform_interface.VideoTrack? track,
+ ) async {
+ calls.add('selectVideoTrack');
+ }
+
+ @override
+ bool isVideoTrackSupportAvailable() {
+ return true;
+ }
}
diff --git a/packages/video_player/video_player_android/CHANGELOG.md b/packages/video_player/video_player_android/CHANGELOG.md
index 560455b2100..fe2b7632aad 100644
--- a/packages/video_player/video_player_android/CHANGELOG.md
+++ b/packages/video_player/video_player_android/CHANGELOG.md
@@ -1,3 +1,7 @@
+## NEXT
+
+* Implements `getVideoTracks()` and `selectVideoTrack()` methods for video track (quality) selection using ExoPlayer.
+
## 2.9.2
* Bumps kotlin_version to 2.3.0.
diff --git a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/ExoPlayerEventListener.java b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/ExoPlayerEventListener.java
index 33988786a78..c776109a741 100644
--- a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/ExoPlayerEventListener.java
+++ b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/ExoPlayerEventListener.java
@@ -95,8 +95,12 @@ public void onIsPlayingChanged(boolean isPlaying) {
@Override
public void onTracksChanged(@NonNull Tracks tracks) {
// Find the currently selected audio track and notify
- String selectedTrackId = findSelectedAudioTrackId(tracks);
- events.onAudioTrackChanged(selectedTrackId);
+ String selectedAudioTrackId = findSelectedAudioTrackId(tracks);
+ events.onAudioTrackChanged(selectedAudioTrackId);
+
+ // Find the currently selected video track and notify
+ String selectedVideoTrackId = findSelectedVideoTrackId(tracks);
+ events.onVideoTrackChanged(selectedVideoTrackId);
}
/**
@@ -121,4 +125,27 @@ private String findSelectedAudioTrackId(@NonNull Tracks tracks) {
}
return null;
}
+
+ /**
+ * Finds the ID of the currently selected video track.
+ *
+ * @param tracks The current tracks
+ * @return The track ID in format "groupIndex_trackIndex", or null if no video track is selected
+ */
+ @Nullable
+ private String findSelectedVideoTrackId(@NonNull Tracks tracks) {
+ int groupIndex = 0;
+ for (Tracks.Group group : tracks.getGroups()) {
+ if (group.getType() == C.TRACK_TYPE_VIDEO && group.isSelected()) {
+ // Find the selected track within this group
+ for (int i = 0; i < group.length; i++) {
+ if (group.isTrackSelected(i)) {
+ return groupIndex + "_" + i;
+ }
+ }
+ }
+ groupIndex++;
+ }
+ return null;
+ }
}
diff --git a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayer.java b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayer.java
index 7cfb5c1c13b..d22152ca2cf 100644
--- a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayer.java
+++ b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayer.java
@@ -233,6 +233,171 @@ public void selectAudioTrack(long groupIndex, long trackIndex) {
trackSelector.buildUponParameters().setOverrideForType(override).build());
}
+ // TODO: Migrate to stable API, see https://github.com/flutter/flutter/issues/147039.
+ @UnstableApi
+ @Override
+ public @NonNull NativeVideoTrackData getVideoTracks() {
+ List videoTracks = new ArrayList<>();
+
+ // Get the current tracks from ExoPlayer
+ Tracks tracks = exoPlayer.getCurrentTracks();
+
+ // Iterate through all track groups
+ for (int groupIndex = 0; groupIndex < tracks.getGroups().size(); groupIndex++) {
+ Tracks.Group group = tracks.getGroups().get(groupIndex);
+
+ // Only process video tracks
+ if (group.getType() == C.TRACK_TYPE_VIDEO) {
+ for (int trackIndex = 0; trackIndex < group.length; trackIndex++) {
+ Format format = group.getTrackFormat(trackIndex);
+ boolean isSelected = group.isTrackSelected(trackIndex);
+
+ // Create video track data with metadata
+ ExoPlayerVideoTrackData videoTrack =
+ new ExoPlayerVideoTrackData(
+ (long) groupIndex,
+ (long) trackIndex,
+ format.label,
+ isSelected,
+ format.bitrate != Format.NO_VALUE ? (long) format.bitrate : null,
+ format.width != Format.NO_VALUE ? (long) format.width : null,
+ format.height != Format.NO_VALUE ? (long) format.height : null,
+ format.frameRate != Format.NO_VALUE ? (double) format.frameRate : null,
+ format.codecs != null ? format.codecs : null);
+
+ videoTracks.add(videoTrack);
+ }
+ }
+ }
+ return new NativeVideoTrackData(videoTracks);
+ }
+
+ // TODO: Migrate to stable API, see https://github.com/flutter/flutter/issues/147039.
+ @UnstableApi
+ @Override
+ public void selectVideoTrack(long groupIndex, long trackIndex) {
+ if (trackSelector == null) {
+ throw new IllegalStateException("Cannot select video track: track selector is null");
+ }
+
+ // If both indices are -1, clear the video track override (auto quality)
+ if (groupIndex == -1 && trackIndex == -1) {
+ // Clear video track override to enable adaptive streaming
+ trackSelector.setParameters(
+ trackSelector.buildUponParameters().clearOverridesOfType(C.TRACK_TYPE_VIDEO).build());
+ return;
+ }
+
+ // Get current tracks
+ Tracks tracks = exoPlayer.getCurrentTracks();
+
+ if (groupIndex < 0 || groupIndex >= tracks.getGroups().size()) {
+ throw new IllegalArgumentException(
+ "Cannot select video track: groupIndex "
+ + groupIndex
+ + " is out of bounds (available groups: "
+ + tracks.getGroups().size()
+ + ")");
+ }
+
+ Tracks.Group group = tracks.getGroups().get((int) groupIndex);
+
+ // Verify it's a video track
+ if (group.getType() != C.TRACK_TYPE_VIDEO) {
+ throw new IllegalArgumentException(
+ "Cannot select video track: group at index "
+ + groupIndex
+ + " is not a video track (type: "
+ + group.getType()
+ + ")");
+ }
+
+ // Verify the track index is valid
+ if (trackIndex < 0 || (int) trackIndex >= group.length) {
+ throw new IllegalArgumentException(
+ "Cannot select video track: trackIndex "
+ + trackIndex
+ + " is out of bounds (available tracks in group: "
+ + group.length
+ + ")");
+ }
+
+ // Get the track group and create a selection override
+ TrackGroup trackGroup = group.getMediaTrackGroup();
+ TrackSelectionOverride override = new TrackSelectionOverride(trackGroup, (int) trackIndex);
+
+ // Check if the new track has different dimensions than the current track
+ Format currentFormat = exoPlayer.getVideoFormat();
+ Format newFormat = trackGroup.getFormat((int) trackIndex);
+ boolean dimensionsChanged =
+ currentFormat != null
+ && (currentFormat.width != newFormat.width || currentFormat.height != newFormat.height);
+
+ // When video dimensions change, we need to force a complete renderer reset to avoid
+ // surface rendering issues. We do this by temporarily disabling the video track type,
+ // which causes ExoPlayer to release the current video renderer and MediaCodec decoder.
+ // After a brief delay, we re-enable video with the new track selection, which creates
+ // a fresh renderer properly configured for the new dimensions.
+ //
+ // Why is this necessary?
+ // When switching between video tracks with different resolutions (e.g., 720p to 1080p),
+ // the existing video surface and MediaCodec decoder may not properly reconfigure for the
+ // new dimensions. This can cause visual glitches where the video appears in the wrong
+ // position (e.g., top-left corner) or the old surface remains partially visible.
+ // By disabling the video track type, we force ExoPlayer to completely release the
+ // current renderer and decoder, ensuring a clean slate for the new resolution.
+ if (dimensionsChanged) {
+ final boolean wasPlaying = exoPlayer.isPlaying();
+ final long currentPosition = exoPlayer.getCurrentPosition();
+
+ // Disable video track type to force renderer release
+ trackSelector.setParameters(
+ trackSelector
+ .buildUponParameters()
+ .setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, true)
+ .build());
+
+ // Re-enable video with the new track selection after allowing renderer to release.
+ //
+ // Why 150ms delay?
+ // This delay is necessary to allow the MediaCodec decoder and video renderer to fully
+ // release their resources before we attempt to create new ones. Without this delay,
+ // the new decoder may be initialized before the old one is completely released, leading
+ // to resource conflicts and rendering artifacts. The 150ms value was determined through
+ // empirical testing across various Android devices and provides a reliable balance
+ // between responsiveness and ensuring complete resource cleanup. Shorter delays (e.g.,
+ // 50-100ms) were found to still cause glitches on some devices, while longer delays
+ // would unnecessarily impact user experience.
+ new android.os.Handler(android.os.Looper.getMainLooper())
+ .postDelayed(
+ () -> {
+ // Guard against player disposal during the delay
+ if (trackSelector == null) {
+ return;
+ }
+
+ trackSelector.setParameters(
+ trackSelector
+ .buildUponParameters()
+ .setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, false)
+ .setOverrideForType(override)
+ .build());
+
+ // Restore playback state
+ exoPlayer.seekTo(currentPosition);
+ if (wasPlaying) {
+ exoPlayer.play();
+ }
+ },
+ 150);
+ return;
+ }
+
+ // Apply the track selection override normally if dimensions haven't changed
+ trackSelector.setParameters(
+ trackSelector.buildUponParameters().setOverrideForType(override).build());
+ }
+
public void dispose() {
if (disposeHandler != null) {
disposeHandler.onDispose();
diff --git a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerCallbacks.java b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerCallbacks.java
index 4cac902319e..45638321c04 100644
--- a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerCallbacks.java
+++ b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerCallbacks.java
@@ -26,4 +26,6 @@ public interface VideoPlayerCallbacks {
void onIsPlayingStateUpdate(boolean isPlaying);
void onAudioTrackChanged(@Nullable String selectedTrackId);
+
+ void onVideoTrackChanged(@Nullable String selectedTrackId);
}
diff --git a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerEventCallbacks.java b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerEventCallbacks.java
index a471ec960e6..21484cc2df3 100644
--- a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerEventCallbacks.java
+++ b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerEventCallbacks.java
@@ -68,4 +68,9 @@ public void onIsPlayingStateUpdate(boolean isPlaying) {
public void onAudioTrackChanged(@Nullable String selectedTrackId) {
eventSink.success(new AudioTrackChangedEvent(selectedTrackId));
}
+
+ @Override
+ public void onVideoTrackChanged(@Nullable String selectedTrackId) {
+ eventSink.success(new VideoTrackChangedEvent(selectedTrackId));
+ }
}
diff --git a/packages/video_player/video_player_android/android/src/main/kotlin/io/flutter/plugins/videoplayer/Messages.kt b/packages/video_player/video_player_android/android/src/main/kotlin/io/flutter/plugins/videoplayer/Messages.kt
index e546c744e56..6faff6e1abe 100644
--- a/packages/video_player/video_player_android/android/src/main/kotlin/io/flutter/plugins/videoplayer/Messages.kt
+++ b/packages/video_player/video_player_android/android/src/main/kotlin/io/flutter/plugins/videoplayer/Messages.kt
@@ -263,6 +263,47 @@ data class AudioTrackChangedEvent(
override fun hashCode(): Int = toList().hashCode()
}
+/**
+ * Sent when video tracks change.
+ *
+ * This includes when the selected video track changes after calling selectVideoTrack. Corresponds
+ * to ExoPlayer's onTracksChanged.
+ *
+ * Generated class from Pigeon that represents data sent in messages.
+ */
+data class VideoTrackChangedEvent(
+ /**
+ * The ID of the newly selected video track, if any. Will be null when auto quality selection is
+ * enabled.
+ */
+ val selectedTrackId: String? = null
+) : PlatformVideoEvent() {
+ companion object {
+ fun fromList(pigeonVar_list: List): VideoTrackChangedEvent {
+ val selectedTrackId = pigeonVar_list[0] as String?
+ return VideoTrackChangedEvent(selectedTrackId)
+ }
+ }
+
+ fun toList(): List {
+ return listOf(
+ selectedTrackId,
+ )
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (other !is VideoTrackChangedEvent) {
+ return false
+ }
+ if (this === other) {
+ return true
+ }
+ return MessagesPigeonUtils.deepEquals(toList(), other.toList())
+ }
+
+ override fun hashCode(): Int = toList().hashCode()
+}
+
/**
* Information passed to the platform view creation.
*
@@ -557,6 +598,100 @@ data class NativeAudioTrackData(
override fun hashCode(): Int = toList().hashCode()
}
+/**
+ * Raw video track data from ExoPlayer Format objects.
+ *
+ * Generated class from Pigeon that represents data sent in messages.
+ */
+data class ExoPlayerVideoTrackData(
+ val groupIndex: Long,
+ val trackIndex: Long,
+ val label: String? = null,
+ val isSelected: Boolean,
+ val bitrate: Long? = null,
+ val width: Long? = null,
+ val height: Long? = null,
+ val frameRate: Double? = null,
+ val codec: String? = null
+) {
+ companion object {
+ fun fromList(pigeonVar_list: List): ExoPlayerVideoTrackData {
+ val groupIndex = pigeonVar_list[0] as Long
+ val trackIndex = pigeonVar_list[1] as Long
+ val label = pigeonVar_list[2] as String?
+ val isSelected = pigeonVar_list[3] as Boolean
+ val bitrate = pigeonVar_list[4] as Long?
+ val width = pigeonVar_list[5] as Long?
+ val height = pigeonVar_list[6] as Long?
+ val frameRate = pigeonVar_list[7] as Double?
+ val codec = pigeonVar_list[8] as String?
+ return ExoPlayerVideoTrackData(
+ groupIndex, trackIndex, label, isSelected, bitrate, width, height, frameRate, codec)
+ }
+ }
+
+ fun toList(): List {
+ return listOf(
+ groupIndex,
+ trackIndex,
+ label,
+ isSelected,
+ bitrate,
+ width,
+ height,
+ frameRate,
+ codec,
+ )
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (other !is ExoPlayerVideoTrackData) {
+ return false
+ }
+ if (this === other) {
+ return true
+ }
+ return MessagesPigeonUtils.deepEquals(toList(), other.toList())
+ }
+
+ override fun hashCode(): Int = toList().hashCode()
+}
+
+/**
+ * Container for raw video track data from Android ExoPlayer.
+ *
+ * Generated class from Pigeon that represents data sent in messages.
+ */
+data class NativeVideoTrackData(
+ /** ExoPlayer-based tracks */
+ val exoPlayerTracks: List? = null
+) {
+ companion object {
+ fun fromList(pigeonVar_list: List): NativeVideoTrackData {
+ val exoPlayerTracks = pigeonVar_list[0] as List?
+ return NativeVideoTrackData(exoPlayerTracks)
+ }
+ }
+
+ fun toList(): List {
+ return listOf(
+ exoPlayerTracks,
+ )
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (other !is NativeVideoTrackData) {
+ return false
+ }
+ if (this === other) {
+ return true
+ }
+ return MessagesPigeonUtils.deepEquals(toList(), other.toList())
+ }
+
+ override fun hashCode(): Int = toList().hashCode()
+}
+
private open class MessagesPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return when (type) {
@@ -579,28 +714,37 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
return (readValue(buffer) as? List)?.let { AudioTrackChangedEvent.fromList(it) }
}
135.toByte() -> {
+ return (readValue(buffer) as? List)?.let { VideoTrackChangedEvent.fromList(it) }
+ }
+ 136.toByte() -> {
return (readValue(buffer) as? List)?.let {
PlatformVideoViewCreationParams.fromList(it)
}
}
- 136.toByte() -> {
+ 137.toByte() -> {
return (readValue(buffer) as? List)?.let { CreationOptions.fromList(it) }
}
- 137.toByte() -> {
+ 138.toByte() -> {
return (readValue(buffer) as? List)?.let { TexturePlayerIds.fromList(it) }
}
- 138.toByte() -> {
+ 139.toByte() -> {
return (readValue(buffer) as? List)?.let { PlaybackState.fromList(it) }
}
- 139.toByte() -> {
+ 140.toByte() -> {
return (readValue(buffer) as? List)?.let { AudioTrackMessage.fromList(it) }
}
- 140.toByte() -> {
+ 141.toByte() -> {
return (readValue(buffer) as? List)?.let { ExoPlayerAudioTrackData.fromList(it) }
}
- 141.toByte() -> {
+ 142.toByte() -> {
return (readValue(buffer) as? List)?.let { NativeAudioTrackData.fromList(it) }
}
+ 143.toByte() -> {
+ return (readValue(buffer) as? List)?.let { ExoPlayerVideoTrackData.fromList(it) }
+ }
+ 144.toByte() -> {
+ return (readValue(buffer) as? List)?.let { NativeVideoTrackData.fromList(it) }
+ }
else -> super.readValueOfType(type, buffer)
}
}
@@ -631,34 +775,46 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
stream.write(134)
writeValue(stream, value.toList())
}
- is PlatformVideoViewCreationParams -> {
+ is VideoTrackChangedEvent -> {
stream.write(135)
writeValue(stream, value.toList())
}
- is CreationOptions -> {
+ is PlatformVideoViewCreationParams -> {
stream.write(136)
writeValue(stream, value.toList())
}
- is TexturePlayerIds -> {
+ is CreationOptions -> {
stream.write(137)
writeValue(stream, value.toList())
}
- is PlaybackState -> {
+ is TexturePlayerIds -> {
stream.write(138)
writeValue(stream, value.toList())
}
- is AudioTrackMessage -> {
+ is PlaybackState -> {
stream.write(139)
writeValue(stream, value.toList())
}
- is ExoPlayerAudioTrackData -> {
+ is AudioTrackMessage -> {
stream.write(140)
writeValue(stream, value.toList())
}
- is NativeAudioTrackData -> {
+ is ExoPlayerAudioTrackData -> {
stream.write(141)
writeValue(stream, value.toList())
}
+ is NativeAudioTrackData -> {
+ stream.write(142)
+ writeValue(stream, value.toList())
+ }
+ is ExoPlayerVideoTrackData -> {
+ stream.write(143)
+ writeValue(stream, value.toList())
+ }
+ is NativeVideoTrackData -> {
+ stream.write(144)
+ writeValue(stream, value.toList())
+ }
else -> super.writeValue(stream, value)
}
}
@@ -854,6 +1010,13 @@ interface VideoPlayerInstanceApi {
fun getAudioTracks(): NativeAudioTrackData
/** Selects which audio track is chosen for playback from its [groupIndex] and [trackIndex] */
fun selectAudioTrack(groupIndex: Long, trackIndex: Long)
+ /** Gets the available video tracks for the video. */
+ fun getVideoTracks(): NativeVideoTrackData
+ /**
+ * Selects which video track is chosen for playback from its [groupIndex] and [trackIndex]. Pass
+ * -1 for both indices to enable auto quality selection.
+ */
+ fun selectVideoTrack(groupIndex: Long, trackIndex: Long)
companion object {
/** The codec used by VideoPlayerInstanceApi. */
@@ -1088,6 +1251,50 @@ interface VideoPlayerInstanceApi {
channel.setMessageHandler(null)
}
}
+ run {
+ val channel =
+ BasicMessageChannel(
+ binaryMessenger,
+ "dev.flutter.pigeon.video_player_android.VideoPlayerInstanceApi.getVideoTracks$separatedMessageChannelSuffix",
+ codec)
+ if (api != null) {
+ channel.setMessageHandler { _, reply ->
+ val wrapped: List =
+ try {
+ listOf(api.getVideoTracks())
+ } catch (exception: Throwable) {
+ MessagesPigeonUtils.wrapError(exception)
+ }
+ reply.reply(wrapped)
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
+ run {
+ val channel =
+ BasicMessageChannel(
+ binaryMessenger,
+ "dev.flutter.pigeon.video_player_android.VideoPlayerInstanceApi.selectVideoTrack$separatedMessageChannelSuffix",
+ codec)
+ if (api != null) {
+ channel.setMessageHandler { message, reply ->
+ val args = message as List
+ val groupIndexArg = args[0] as Long
+ val trackIndexArg = args[1] as Long
+ val wrapped: List =
+ try {
+ api.selectVideoTrack(groupIndexArg, trackIndexArg)
+ listOf(null)
+ } catch (exception: Throwable) {
+ MessagesPigeonUtils.wrapError(exception)
+ }
+ reply.reply(wrapped)
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
}
}
}
diff --git a/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoPlayerEventCallbacksTest.java b/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoPlayerEventCallbacksTest.java
index acb3bfd2b40..b6eb88c8c30 100644
--- a/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoPlayerEventCallbacksTest.java
+++ b/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoPlayerEventCallbacksTest.java
@@ -88,4 +88,28 @@ public void onIsPlayingStateUpdate() {
IsPlayingStateEvent expected = new IsPlayingStateEvent(true);
assertEquals(expected, actual);
}
+
+ @Test
+ public void onAudioTrackChanged() {
+ String trackId = "0_1";
+ eventCallbacks.onAudioTrackChanged(trackId);
+
+ verify(mockEventSink).success(eventCaptor.capture());
+
+ PlatformVideoEvent actual = eventCaptor.getValue();
+ AudioTrackChangedEvent expected = new AudioTrackChangedEvent(trackId);
+ assertEquals(expected, actual);
+ }
+
+ @Test
+ public void onVideoTrackChanged() {
+ String trackId = "0_2";
+ eventCallbacks.onVideoTrackChanged(trackId);
+
+ verify(mockEventSink).success(eventCaptor.capture());
+
+ PlatformVideoEvent actual = eventCaptor.getValue();
+ VideoTrackChangedEvent expected = new VideoTrackChangedEvent(trackId);
+ assertEquals(expected, actual);
+ }
}
diff --git a/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoPlayerTest.java b/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoPlayerTest.java
index 92c2ff5f156..cfb271b7085 100644
--- a/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoPlayerTest.java
+++ b/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoPlayerTest.java
@@ -620,4 +620,401 @@ public void testSelectAudioTrack_negativeIndices() {
videoPlayer.dispose();
}
+
+ // ==================== Video Track Tests ====================
+
+ @Test
+ public void testGetVideoTracks_withMultipleVideoTracks() {
+ Tracks mockTracks = mock(Tracks.class);
+ Tracks.Group mockVideoGroup1 = mock(Tracks.Group.class);
+ Tracks.Group mockVideoGroup2 = mock(Tracks.Group.class);
+ Tracks.Group mockAudioGroup = mock(Tracks.Group.class);
+
+ // Create mock formats for video tracks
+ Format videoFormat1 =
+ new Format.Builder()
+ .setId("video_track_1")
+ .setLabel("1080p")
+ .setAverageBitrate(5000000)
+ .setWidth(1920)
+ .setHeight(1080)
+ .setFrameRate(30.0f)
+ .setCodecs("avc1.64001f")
+ .build();
+
+ Format videoFormat2 =
+ new Format.Builder()
+ .setId("video_track_2")
+ .setLabel("720p")
+ .setAverageBitrate(2500000)
+ .setWidth(1280)
+ .setHeight(720)
+ .setFrameRate(24.0f)
+ .setCodecs("avc1.4d401f")
+ .build();
+
+ // Mock video groups and set length field
+ setGroupLength(mockVideoGroup1, 1);
+ setGroupLength(mockVideoGroup2, 1);
+
+ when(mockVideoGroup1.getType()).thenReturn(C.TRACK_TYPE_VIDEO);
+ when(mockVideoGroup1.getTrackFormat(0)).thenReturn(videoFormat1);
+ when(mockVideoGroup1.isTrackSelected(0)).thenReturn(true);
+
+ when(mockVideoGroup2.getType()).thenReturn(C.TRACK_TYPE_VIDEO);
+ when(mockVideoGroup2.getTrackFormat(0)).thenReturn(videoFormat2);
+ when(mockVideoGroup2.isTrackSelected(0)).thenReturn(false);
+
+ when(mockAudioGroup.getType()).thenReturn(C.TRACK_TYPE_AUDIO);
+
+ // Mock tracks
+ ImmutableList groups =
+ ImmutableList.of(mockVideoGroup1, mockVideoGroup2, mockAudioGroup);
+ when(mockTracks.getGroups()).thenReturn(groups);
+ when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks);
+
+ VideoPlayer videoPlayer = createVideoPlayer();
+
+ // Test the method
+ NativeVideoTrackData nativeData = videoPlayer.getVideoTracks();
+ List result = nativeData.getExoPlayerTracks();
+
+ // Verify results
+ assertNotNull(result);
+ assertEquals(2, result.size());
+
+ // Verify first track
+ ExoPlayerVideoTrackData track1 = result.get(0);
+ assertEquals(0L, track1.getGroupIndex());
+ assertEquals(0L, track1.getTrackIndex());
+ assertEquals("1080p", track1.getLabel());
+ assertTrue(track1.isSelected());
+ assertEquals(Long.valueOf(5000000), track1.getBitrate());
+ assertEquals(Long.valueOf(1920), track1.getWidth());
+ assertEquals(Long.valueOf(1080), track1.getHeight());
+ assertEquals(Double.valueOf(30.0), track1.getFrameRate());
+ assertEquals("avc1.64001f", track1.getCodec());
+
+ // Verify second track
+ ExoPlayerVideoTrackData track2 = result.get(1);
+ assertEquals(1L, track2.getGroupIndex());
+ assertEquals(0L, track2.getTrackIndex());
+ assertEquals("720p", track2.getLabel());
+ assertFalse(track2.isSelected());
+ assertEquals(Long.valueOf(2500000), track2.getBitrate());
+ assertEquals(Long.valueOf(1280), track2.getWidth());
+ assertEquals(Long.valueOf(720), track2.getHeight());
+ assertEquals(Double.valueOf(24.0), track2.getFrameRate());
+ assertEquals("avc1.4d401f", track2.getCodec());
+
+ videoPlayer.dispose();
+ }
+
+ @Test
+ public void testGetVideoTracks_withNoVideoTracks() {
+ Tracks mockTracks = mock(Tracks.class);
+ Tracks.Group mockAudioGroup = mock(Tracks.Group.class);
+
+ // Mock audio group only (no video tracks)
+ when(mockAudioGroup.getType()).thenReturn(C.TRACK_TYPE_AUDIO);
+
+ ImmutableList groups = ImmutableList.of(mockAudioGroup);
+ when(mockTracks.getGroups()).thenReturn(groups);
+ when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks);
+
+ VideoPlayer videoPlayer = createVideoPlayer();
+
+ // Test the method
+ NativeVideoTrackData nativeData = videoPlayer.getVideoTracks();
+ List result = nativeData.getExoPlayerTracks();
+
+ // Verify results
+ assertNotNull(result);
+ assertEquals(0, result.size());
+
+ videoPlayer.dispose();
+ }
+
+ @Test
+ public void testGetVideoTracks_withNullValues() {
+ Tracks mockTracks = mock(Tracks.class);
+ Tracks.Group mockVideoGroup = mock(Tracks.Group.class);
+
+ // Create format with null/missing values
+ Format videoFormat =
+ new Format.Builder()
+ .setId("video_track_null")
+ .setLabel(null) // Null label
+ .setAverageBitrate(Format.NO_VALUE) // No bitrate
+ .setWidth(Format.NO_VALUE) // No width
+ .setHeight(Format.NO_VALUE) // No height
+ .setFrameRate(Format.NO_VALUE) // No frame rate
+ .setCodecs(null) // Null codec
+ .build();
+
+ // Mock video group and set length field
+ setGroupLength(mockVideoGroup, 1);
+ when(mockVideoGroup.getType()).thenReturn(C.TRACK_TYPE_VIDEO);
+ when(mockVideoGroup.getTrackFormat(0)).thenReturn(videoFormat);
+ when(mockVideoGroup.isTrackSelected(0)).thenReturn(false);
+
+ ImmutableList groups = ImmutableList.of(mockVideoGroup);
+ when(mockTracks.getGroups()).thenReturn(groups);
+ when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks);
+
+ VideoPlayer videoPlayer = createVideoPlayer();
+
+ // Test the method
+ NativeVideoTrackData nativeData = videoPlayer.getVideoTracks();
+ List result = nativeData.getExoPlayerTracks();
+
+ // Verify results
+ assertNotNull(result);
+ assertEquals(1, result.size());
+
+ ExoPlayerVideoTrackData track = result.get(0);
+ assertEquals(0L, track.getGroupIndex());
+ assertEquals(0L, track.getTrackIndex());
+ assertNull(track.getLabel()); // Null values should be preserved
+ assertFalse(track.isSelected());
+ assertNull(track.getBitrate());
+ assertNull(track.getWidth());
+ assertNull(track.getHeight());
+ assertNull(track.getFrameRate());
+ assertNull(track.getCodec());
+
+ videoPlayer.dispose();
+ }
+
+ @Test
+ public void testGetVideoTracks_withMultipleTracksInSameGroup() {
+ Tracks mockTracks = mock(Tracks.class);
+ Tracks.Group mockVideoGroup = mock(Tracks.Group.class);
+
+ // Create formats for group with multiple tracks (adaptive streaming scenario)
+ Format videoFormat1 =
+ new Format.Builder()
+ .setId("video_track_1")
+ .setLabel("1080p")
+ .setWidth(1920)
+ .setHeight(1080)
+ .setAverageBitrate(5000000)
+ .build();
+
+ Format videoFormat2 =
+ new Format.Builder()
+ .setId("video_track_2")
+ .setLabel("720p")
+ .setWidth(1280)
+ .setHeight(720)
+ .setAverageBitrate(2500000)
+ .build();
+
+ // Mock video group with multiple tracks
+ setGroupLength(mockVideoGroup, 2);
+ when(mockVideoGroup.getType()).thenReturn(C.TRACK_TYPE_VIDEO);
+ when(mockVideoGroup.getTrackFormat(0)).thenReturn(videoFormat1);
+ when(mockVideoGroup.getTrackFormat(1)).thenReturn(videoFormat2);
+ when(mockVideoGroup.isTrackSelected(0)).thenReturn(true);
+ when(mockVideoGroup.isTrackSelected(1)).thenReturn(false);
+
+ ImmutableList groups = ImmutableList.of(mockVideoGroup);
+ when(mockTracks.getGroups()).thenReturn(groups);
+ when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks);
+
+ VideoPlayer videoPlayer = createVideoPlayer();
+
+ // Test the method
+ NativeVideoTrackData nativeData = videoPlayer.getVideoTracks();
+ List result = nativeData.getExoPlayerTracks();
+
+ // Verify results
+ assertNotNull(result);
+ assertEquals(2, result.size());
+
+ // Verify track indices are correct
+ ExoPlayerVideoTrackData track1 = result.get(0);
+ ExoPlayerVideoTrackData track2 = result.get(1);
+ assertEquals(0L, track1.getGroupIndex());
+ assertEquals(0L, track1.getTrackIndex());
+ assertEquals(0L, track2.getGroupIndex());
+ assertEquals(1L, track2.getTrackIndex());
+ // Tracks have same group but different track indices
+ assertEquals(track1.getGroupIndex(), track2.getGroupIndex());
+ assertNotEquals(track1.getTrackIndex(), track2.getTrackIndex());
+
+ videoPlayer.dispose();
+ }
+
+ @Test
+ public void testSelectVideoTrack_validIndices() {
+ DefaultTrackSelector mockTrackSelector = mock(DefaultTrackSelector.class);
+ DefaultTrackSelector.Parameters mockParameters = mock(DefaultTrackSelector.Parameters.class);
+ DefaultTrackSelector.Parameters.Builder mockBuilder =
+ mock(DefaultTrackSelector.Parameters.Builder.class);
+
+ Tracks mockTracks = mock(Tracks.class);
+ Tracks.Group mockVideoGroup = mock(Tracks.Group.class);
+
+ Format videoFormat =
+ new Format.Builder()
+ .setId("video_track_1")
+ .setLabel("1080p")
+ .setWidth(1920)
+ .setHeight(1080)
+ .build();
+
+ // Create a real TrackGroup with the format
+ TrackGroup trackGroup = new TrackGroup(videoFormat);
+
+ // Mock video group with 2 tracks
+ setGroupLength(mockVideoGroup, 2);
+ when(mockVideoGroup.getType()).thenReturn(C.TRACK_TYPE_VIDEO);
+ when(mockVideoGroup.getMediaTrackGroup()).thenReturn(trackGroup);
+
+ ImmutableList groups = ImmutableList.of(mockVideoGroup);
+ when(mockTracks.getGroups()).thenReturn(groups);
+
+ // Set up track selector BEFORE creating VideoPlayer
+ when(mockExoPlayer.getTrackSelector()).thenReturn(mockTrackSelector);
+ when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks);
+ when(mockExoPlayer.getVideoFormat()).thenReturn(videoFormat);
+ when(mockTrackSelector.buildUponParameters()).thenReturn(mockBuilder);
+ when(mockBuilder.setOverrideForType(any(TrackSelectionOverride.class))).thenReturn(mockBuilder);
+ when(mockBuilder.setTrackTypeDisabled(anyInt(), anyBoolean())).thenReturn(mockBuilder);
+ when(mockBuilder.build()).thenReturn(mockParameters);
+
+ VideoPlayer videoPlayer = createVideoPlayer();
+
+ // Test selecting a valid video track
+ videoPlayer.selectVideoTrack(0, 0);
+
+ // Verify track selector was called
+ verify(mockTrackSelector, atLeastOnce()).buildUponParameters();
+ verify(mockBuilder, atLeastOnce()).build();
+ verify(mockTrackSelector, atLeastOnce()).setParameters(mockParameters);
+
+ videoPlayer.dispose();
+ }
+
+ @Test
+ public void testSelectVideoTrack_nullTrackSelector() {
+ // Track selector is null by default in mock
+ VideoPlayer videoPlayer = createVideoPlayer();
+
+ assertThrows(IllegalStateException.class, () -> videoPlayer.selectVideoTrack(0, 0));
+
+ videoPlayer.dispose();
+ }
+
+ @Test
+ public void testSelectVideoTrack_invalidGroupIndex() {
+ DefaultTrackSelector mockTrackSelector = mock(DefaultTrackSelector.class);
+ Tracks mockTracks = mock(Tracks.class);
+ Tracks.Group mockVideoGroup = mock(Tracks.Group.class);
+
+ ImmutableList groups = ImmutableList.of(mockVideoGroup);
+ when(mockTracks.getGroups()).thenReturn(groups);
+ when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks);
+ when(mockExoPlayer.getTrackSelector()).thenReturn(mockTrackSelector);
+
+ VideoPlayer videoPlayer = createVideoPlayer();
+
+ // Test with invalid group index (only 1 group exists at index 0)
+ assertThrows(IllegalArgumentException.class, () -> videoPlayer.selectVideoTrack(5, 0));
+
+ videoPlayer.dispose();
+ }
+
+ @Test
+ public void testSelectVideoTrack_invalidTrackIndex() {
+ DefaultTrackSelector mockTrackSelector = mock(DefaultTrackSelector.class);
+ Tracks mockTracks = mock(Tracks.class);
+ Tracks.Group mockVideoGroup = mock(Tracks.Group.class);
+
+ // Mock video group with only 1 track
+ setGroupLength(mockVideoGroup, 1);
+ when(mockVideoGroup.getType()).thenReturn(C.TRACK_TYPE_VIDEO);
+
+ ImmutableList groups = ImmutableList.of(mockVideoGroup);
+ when(mockTracks.getGroups()).thenReturn(groups);
+ when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks);
+ when(mockExoPlayer.getTrackSelector()).thenReturn(mockTrackSelector);
+
+ VideoPlayer videoPlayer = createVideoPlayer();
+
+ // Test with invalid track index (only 1 track exists at index 0)
+ assertThrows(IllegalArgumentException.class, () -> videoPlayer.selectVideoTrack(0, 5));
+
+ videoPlayer.dispose();
+ }
+
+ @Test
+ public void testSelectVideoTrack_nonVideoGroup() {
+ DefaultTrackSelector mockTrackSelector = mock(DefaultTrackSelector.class);
+ Tracks mockTracks = mock(Tracks.class);
+ Tracks.Group mockAudioGroup = mock(Tracks.Group.class);
+
+ // Mock audio group (not video)
+ setGroupLength(mockAudioGroup, 1);
+ when(mockAudioGroup.getType()).thenReturn(C.TRACK_TYPE_AUDIO);
+
+ ImmutableList groups = ImmutableList.of(mockAudioGroup);
+ when(mockTracks.getGroups()).thenReturn(groups);
+ when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks);
+ when(mockExoPlayer.getTrackSelector()).thenReturn(mockTrackSelector);
+
+ VideoPlayer videoPlayer = createVideoPlayer();
+
+ // Test selecting from a non-video group
+ assertThrows(IllegalArgumentException.class, () -> videoPlayer.selectVideoTrack(0, 0));
+
+ videoPlayer.dispose();
+ }
+
+ @Test
+ public void testSelectVideoTrack_negativeIndices() {
+ DefaultTrackSelector mockTrackSelector = mock(DefaultTrackSelector.class);
+ Tracks mockTracks = mock(Tracks.class);
+ Tracks.Group mockVideoGroup = mock(Tracks.Group.class);
+
+ ImmutableList groups = ImmutableList.of(mockVideoGroup);
+ when(mockTracks.getGroups()).thenReturn(groups);
+ when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks);
+ when(mockExoPlayer.getTrackSelector()).thenReturn(mockTrackSelector);
+
+ VideoPlayer videoPlayer = createVideoPlayer();
+
+ // Test with negative group index only (not both -1)
+ assertThrows(IllegalArgumentException.class, () -> videoPlayer.selectVideoTrack(-1, 0));
+
+ videoPlayer.dispose();
+ }
+
+ @Test
+ public void testSelectVideoTrack_autoQuality() {
+ DefaultTrackSelector mockTrackSelector = mock(DefaultTrackSelector.class);
+ DefaultTrackSelector.Parameters mockParameters = mock(DefaultTrackSelector.Parameters.class);
+ DefaultTrackSelector.Parameters.Builder mockBuilder =
+ mock(DefaultTrackSelector.Parameters.Builder.class);
+
+ // Set up track selector
+ when(mockExoPlayer.getTrackSelector()).thenReturn(mockTrackSelector);
+ when(mockTrackSelector.buildUponParameters()).thenReturn(mockBuilder);
+ when(mockBuilder.clearOverridesOfType(C.TRACK_TYPE_VIDEO)).thenReturn(mockBuilder);
+ when(mockBuilder.build()).thenReturn(mockParameters);
+
+ VideoPlayer videoPlayer = createVideoPlayer();
+
+ // Test selecting auto quality (both indices -1)
+ videoPlayer.selectVideoTrack(-1, -1);
+
+ // Verify track selector cleared video overrides
+ verify(mockTrackSelector).buildUponParameters();
+ verify(mockBuilder).clearOverridesOfType(C.TRACK_TYPE_VIDEO);
+ verify(mockBuilder).build();
+ verify(mockTrackSelector).setParameters(mockParameters);
+
+ videoPlayer.dispose();
+ }
}
diff --git a/packages/video_player/video_player_android/example/pubspec.yaml b/packages/video_player/video_player_android/example/pubspec.yaml
index 07c5b497d5d..079e64d56f7 100644
--- a/packages/video_player/video_player_android/example/pubspec.yaml
+++ b/packages/video_player/video_player_android/example/pubspec.yaml
@@ -18,7 +18,8 @@ dependencies:
# The example app is bundled with the plugin so we use a path dependency on
# the parent directory to use the current plugin's version.
path: ../
- video_player_platform_interface: ^6.6.0
+ video_player_platform_interface:
+ path: ../../video_player_platform_interface
dev_dependencies:
espresso: ^0.4.0
diff --git a/packages/video_player/video_player_android/lib/src/android_video_player.dart b/packages/video_player/video_player_android/lib/src/android_video_player.dart
index 84249bd41af..27d6de8c908 100644
--- a/packages/video_player/video_player_android/lib/src/android_video_player.dart
+++ b/packages/video_player/video_player_android/lib/src/android_video_player.dart
@@ -266,6 +266,53 @@ class AndroidVideoPlayer extends VideoPlayerPlatform {
return true;
}
+ @override
+ Future> getVideoTracks(int playerId) async {
+ final NativeVideoTrackData nativeData = await _playerWith(
+ id: playerId,
+ ).getVideoTracks();
+ final tracks = [];
+
+ // Convert ExoPlayer tracks to VideoTrack
+ if (nativeData.exoPlayerTracks != null) {
+ for (final ExoPlayerVideoTrackData track in nativeData.exoPlayerTracks!) {
+ // Construct a string ID from groupIndex and trackIndex for compatibility
+ final trackId = '${track.groupIndex}_${track.trackIndex}';
+ // Generate label from resolution if not provided
+ final String? label =
+ track.label ??
+ (track.width != null && track.height != null
+ ? '${track.height}p'
+ : null);
+ tracks.add(
+ VideoTrack(
+ id: trackId,
+ isSelected: track.isSelected,
+ label: label,
+ bitrate: track.bitrate,
+ width: track.width,
+ height: track.height,
+ frameRate: track.frameRate,
+ codec: track.codec,
+ ),
+ );
+ }
+ }
+
+ return tracks;
+ }
+
+ @override
+ Future selectVideoTrack(int playerId, VideoTrack? track) {
+ return _playerWith(id: playerId).selectVideoTrack(track);
+ }
+
+ @override
+ bool isVideoTrackSupportAvailable() {
+ // Android with ExoPlayer supports video track selection
+ return true;
+ }
+
_PlayerInstance _playerWith({required int id}) {
final _PlayerInstance? player = _players[id];
return player ?? (throw StateError('No active player with ID $id.'));
@@ -314,6 +361,8 @@ class _PlayerInstance {
int _lastBufferPosition = -1;
bool _isBuffering = false;
Completer? _audioTrackSelectionCompleter;
+ Completer? _videoTrackSelectionCompleter;
+ String? _expectedVideoTrackId;
final VideoPlayerViewState viewState;
@@ -384,6 +433,63 @@ class _PlayerInstance {
}
}
+ Future getVideoTracks() {
+ return _api.getVideoTracks();
+ }
+
+ Future selectVideoTrack(VideoTrack? track) async {
+ // Create a completer to wait for the track selection to complete
+ _videoTrackSelectionCompleter = Completer();
+
+ if (track == null) {
+ // Auto quality - pass -1, -1 to clear overrides
+ _expectedVideoTrackId = null;
+ try {
+ await _api.selectVideoTrack(-1, -1);
+
+ // Wait for the onTracksChanged event from ExoPlayer with a timeout
+ await _videoTrackSelectionCompleter!.future.timeout(
+ const Duration(seconds: 5),
+ onTimeout: () {
+ // If we timeout, just continue - the track may still have been selected
+ },
+ );
+ } finally {
+ _videoTrackSelectionCompleter = null;
+ _expectedVideoTrackId = null;
+ }
+ return;
+ }
+
+ // Extract groupIndex and trackIndex from the track id
+ final List parts = track.id.split('_');
+ if (parts.length != 2) {
+ throw ArgumentError(
+ 'Invalid track id format: "${track.id}". Expected format: "groupIndex_trackIndex"',
+ );
+ }
+
+ final int groupIndex = int.parse(parts[0]);
+ final int trackIndex = int.parse(parts[1]);
+
+ _expectedVideoTrackId = track.id;
+
+ try {
+ await _api.selectVideoTrack(groupIndex, trackIndex);
+
+ // Wait for the onTracksChanged event from ExoPlayer with a timeout
+ await _videoTrackSelectionCompleter!.future.timeout(
+ const Duration(seconds: 5),
+ onTimeout: () {
+ // If we timeout, just continue - the track may still have been selected
+ },
+ );
+ } finally {
+ _videoTrackSelectionCompleter = null;
+ _expectedVideoTrackId = null;
+ }
+ }
+
Future dispose() async {
_isDisposed = true;
_bufferPollingTimer?.cancel();
@@ -487,6 +593,19 @@ class _PlayerInstance {
!_audioTrackSelectionCompleter!.isCompleted) {
_audioTrackSelectionCompleter!.complete();
}
+ case VideoTrackChangedEvent _:
+ // Complete the video track selection completer only if:
+ // 1. A completer exists (we're waiting for a selection)
+ // 2. The completer hasn't already completed
+ // 3. The selected track ID matches what we're expecting (or we're expecting null for auto)
+ if (_videoTrackSelectionCompleter != null &&
+ !_videoTrackSelectionCompleter!.isCompleted) {
+ // Complete if the track ID matches our expectation, or if we expected null (auto mode)
+ if (_expectedVideoTrackId == null ||
+ event.selectedTrackId == _expectedVideoTrackId) {
+ _videoTrackSelectionCompleter!.complete();
+ }
+ }
}
}
diff --git a/packages/video_player/video_player_android/lib/src/messages.g.dart b/packages/video_player/video_player_android/lib/src/messages.g.dart
index 1aca7dc531d..765791594b8 100644
--- a/packages/video_player/video_player_android/lib/src/messages.g.dart
+++ b/packages/video_player/video_player_android/lib/src/messages.g.dart
@@ -218,6 +218,47 @@ class AudioTrackChangedEvent extends PlatformVideoEvent {
int get hashCode => Object.hashAll(_toList());
}
+/// Sent when video tracks change.
+///
+/// This includes when the selected video track changes after calling selectVideoTrack.
+/// Corresponds to ExoPlayer's onTracksChanged.
+class VideoTrackChangedEvent extends PlatformVideoEvent {
+ VideoTrackChangedEvent({this.selectedTrackId});
+
+ /// The ID of the newly selected video track, if any.
+ /// Will be null when auto quality selection is enabled.
+ String? selectedTrackId;
+
+ List