diff --git a/packages/video_player/video_player/CHANGELOG.md b/packages/video_player/video_player/CHANGELOG.md
index 3524be91edc..74d50ef0a7d 100644
--- a/packages/video_player/video_player/CHANGELOG.md
+++ b/packages/video_player/video_player/CHANGELOG.md
@@ -5,6 +5,7 @@
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 track (quality) selection support via `getVideoTracks()`, `selectVideoTrack()`, and `isVideoTrackSupportAvailable()` methods.
## 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..5b08c984d24 100644
--- a/packages/video_player/video_player/example/lib/main.dart
+++ b/packages/video_player/video_player/example/lib/main.dart
@@ -11,6 +11,8 @@ 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 +27,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..6a897c79e96
--- /dev/null
+++ b/packages/video_player/video_player/example/lib/video_tracks_demo.dart
@@ -0,0 +1,458 @@
+// 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;
+ }
+
+ 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.of(context).showSnackBar(SnackBar(content: Text(message)));
+ } catch (e) {
+ if (!mounted) {
+ return;
+ }
+ ScaffoldMessenger.of(
+ context,
+ ).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..a4396ef5dd6 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,73 @@ 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 streams, this returns the different
+ /// quality levels available. For regular videos, this may return a single
+ /// track or an empty list.
+ ///
+ /// 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 +1292,116 @@ class ClosedCaption extends StatelessWidget {
);
}
}
+
+/// Represents a video track (quality variant) in a video with its metadata.
+///
+/// For HLS/DASH streams, each [VideoTrack] represents a different quality
+/// level (e.g., 1080p, 720p, 480p). For regular videos, there may be only
+/// one track or none available.
+@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, `"asset_{trackID}"` for regular videos
+ 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..61cb3cb6ba6 100644
--- a/packages/video_player/video_player/pubspec.yaml
+++ b/packages/video_player/video_player/pubspec.yaml
@@ -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..1c60063d2c9 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);
@@ -81,13 +85,19 @@ class FakeController extends ValueNotifier
void setCaptionOffset(Duration delay) {}
@override
- Future setClosedCaptionFile(
- Future? closedCaptionFile,
- ) async {}
+ Future setClosedCaptionFile(Future? closedCaptionFile) async {}
+
+ @override
+ Future> getVideoTracks() async => [];
+
+ @override
+ Future selectVideoTrack(VideoTrack? track) async {}
+
+ @override
+ bool isVideoTrackSupportAvailable() => false;
}
-Future _loadClosedCaption() async =>
- _FakeClosedCaptionFile();
+Future _loadClosedCaption() async => _FakeClosedCaptionFile();
class _FakeClosedCaptionFile extends ClosedCaptionFile {
@override
@@ -122,13 +132,9 @@ void main() {
required bool shouldPlayInBackground,
}) {
expect(controller.value.isPlaying, true);
- WidgetsBinding.instance.handleAppLifecycleStateChanged(
- AppLifecycleState.paused,
- );
+ WidgetsBinding.instance.handleAppLifecycleStateChanged(AppLifecycleState.paused);
expect(controller.value.isPlaying, shouldPlayInBackground);
- WidgetsBinding.instance.handleAppLifecycleStateChanged(
- AppLifecycleState.resumed,
- );
+ WidgetsBinding.instance.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
expect(controller.value.isPlaying, true);
}
@@ -172,38 +178,37 @@ void main() {
);
});
- testWidgets(
- 'VideoPlayer still listens for controller changes when reparented',
- (WidgetTester tester) async {
- final controller = FakeController();
- addTearDown(controller.dispose);
- final GlobalKey videoKey = GlobalKey();
- final Widget videoPlayer = KeyedSubtree(
- key: videoKey,
- child: VideoPlayer(controller),
- );
+ testWidgets('VideoPlayer still listens for controller changes when reparented', (
+ WidgetTester tester,
+ ) async {
+ final controller = FakeController();
+ addTearDown(controller.dispose);
+ final GlobalKey videoKey = GlobalKey();
+ final Widget videoPlayer = KeyedSubtree(
+ key: videoKey,
+ child: VideoPlayer(controller),
+ );
- await tester.pumpWidget(videoPlayer);
- expect(find.byType(Texture), findsNothing);
+ await tester.pumpWidget(videoPlayer);
+ expect(find.byType(Texture), findsNothing);
- // The VideoPlayer is reparented in the widget tree, before the
- // underlying player is initialized.
- await tester.pumpWidget(SizedBox(child: videoPlayer));
- controller.playerId = 321;
- controller.value = controller.value.copyWith(
- duration: const Duration(milliseconds: 100),
- isInitialized: true,
- );
+ // The VideoPlayer is reparented in the widget tree, before the
+ // underlying player is initialized.
+ await tester.pumpWidget(SizedBox(child: videoPlayer));
+ controller.playerId = 321;
+ controller.value = controller.value.copyWith(
+ duration: const Duration(milliseconds: 100),
+ isInitialized: true,
+ );
- await tester.pump();
- expect(
- find.byWidgetPredicate(
- (Widget widget) => widget is Texture && widget.textureId == 321,
- ),
- findsOneWidget,
- );
- },
- );
+ await tester.pump();
+ expect(
+ find.byWidgetPredicate(
+ (Widget widget) => widget is Texture && widget.textureId == 321,
+ ),
+ findsOneWidget,
+ );
+ });
testWidgets(
'VideoProgressIndicator still listens for controller changes after reparenting',
@@ -224,9 +229,7 @@ void main() {
);
await tester.pumpWidget(MaterialApp(home: progressIndicator));
await tester.pump();
- await tester.pumpWidget(
- MaterialApp(home: SizedBox(child: progressIndicator)),
- );
+ await tester.pumpWidget(MaterialApp(home: SizedBox(child: progressIndicator)));
expect((key.currentContext! as Element).dirty, isFalse);
// Verify that changing value dirties the widget tree.
controller.value = controller.value.copyWith(
@@ -246,16 +249,12 @@ void main() {
isInitialized: true,
);
await tester.pumpWidget(
- MaterialApp(
- home: VideoProgressIndicator(controller, allowScrubbing: false),
- ),
+ MaterialApp(home: VideoProgressIndicator(controller, allowScrubbing: false)),
);
expect(tester.takeException(), isNull);
});
- testWidgets('non-zero rotationCorrection value is used', (
- WidgetTester tester,
- ) async {
+ testWidgets('non-zero rotationCorrection value is used', (WidgetTester tester) async {
final controller = FakeController.value(
const VideoPlayerValue(duration: Duration.zero, rotationCorrection: 180),
);
@@ -283,9 +282,7 @@ void main() {
group('ClosedCaption widget', () {
testWidgets('uses a default text style', (WidgetTester tester) async {
const text = 'foo';
- await tester.pumpWidget(
- const MaterialApp(home: ClosedCaption(text: text)),
- );
+ await tester.pumpWidget(const MaterialApp(home: ClosedCaption(text: text)));
final Text textWidget = tester.widget(find.text(text));
expect(textWidget.style!.fontSize, 36.0);
@@ -316,9 +313,7 @@ void main() {
expect(find.byType(Text), findsNothing);
});
- testWidgets('Passes text contrast ratio guidelines', (
- WidgetTester tester,
- ) async {
+ testWidgets('Passes text contrast ratio guidelines', (WidgetTester tester) async {
const text = 'foo';
await tester.pumpWidget(
const MaterialApp(
@@ -342,10 +337,7 @@ void main() {
expect(fakeVideoPlayerPlatform.dataSources[0].uri, 'https://127.0.0.1');
expect(fakeVideoPlayerPlatform.dataSources[0].formatHint, null);
- expect(
- fakeVideoPlayerPlatform.dataSources[0].httpHeaders,
- {},
- );
+ expect(fakeVideoPlayerPlatform.dataSources[0].httpHeaders, {});
});
test('network with hint', () async {
@@ -356,14 +348,8 @@ void main() {
await controller.initialize();
expect(fakeVideoPlayerPlatform.dataSources[0].uri, 'https://127.0.0.1');
- expect(
- fakeVideoPlayerPlatform.dataSources[0].formatHint,
- VideoFormat.dash,
- );
- expect(
- fakeVideoPlayerPlatform.dataSources[0].httpHeaders,
- {},
- );
+ expect(fakeVideoPlayerPlatform.dataSources[0].formatHint, VideoFormat.dash);
+ expect(fakeVideoPlayerPlatform.dataSources[0].httpHeaders, {});
});
test('network with some headers', () async {
@@ -375,10 +361,9 @@ void main() {
expect(fakeVideoPlayerPlatform.dataSources[0].uri, 'https://127.0.0.1');
expect(fakeVideoPlayerPlatform.dataSources[0].formatHint, null);
- expect(
- fakeVideoPlayerPlatform.dataSources[0].httpHeaders,
- {'Authorization': 'Bearer token'},
- );
+ expect(fakeVideoPlayerPlatform.dataSources[0].httpHeaders, {
+ 'Authorization': 'Bearer token',
+ });
});
});
@@ -390,10 +375,7 @@ void main() {
addTearDown(controller.dispose);
await controller.initialize();
await controller.play();
- verifyPlayStateRespondsToLifecycle(
- controller,
- shouldPlayInBackground: false,
- );
+ verifyPlayStateRespondsToLifecycle(controller, shouldPlayInBackground: false);
});
test('asset', () async {
@@ -413,10 +395,7 @@ void main() {
expect(fakeVideoPlayerPlatform.dataSources[0].uri, 'https://127.0.0.1');
expect(fakeVideoPlayerPlatform.dataSources[0].formatHint, null);
- expect(
- fakeVideoPlayerPlatform.dataSources[0].httpHeaders,
- {},
- );
+ expect(fakeVideoPlayerPlatform.dataSources[0].httpHeaders, {});
});
test('network url with hint', () async {
@@ -428,14 +407,8 @@ void main() {
await controller.initialize();
expect(fakeVideoPlayerPlatform.dataSources[0].uri, 'https://127.0.0.1');
- expect(
- fakeVideoPlayerPlatform.dataSources[0].formatHint,
- VideoFormat.dash,
- );
- expect(
- fakeVideoPlayerPlatform.dataSources[0].httpHeaders,
- {},
- );
+ expect(fakeVideoPlayerPlatform.dataSources[0].formatHint, VideoFormat.dash);
+ expect(fakeVideoPlayerPlatform.dataSources[0].httpHeaders, {});
});
test('network url with some headers', () async {
@@ -448,10 +421,9 @@ void main() {
expect(fakeVideoPlayerPlatform.dataSources[0].uri, 'https://127.0.0.1');
expect(fakeVideoPlayerPlatform.dataSources[0].formatHint, null);
- expect(
- fakeVideoPlayerPlatform.dataSources[0].httpHeaders,
- {'Authorization': 'Bearer token'},
- );
+ expect(fakeVideoPlayerPlatform.dataSources[0].httpHeaders, {
+ 'Authorization': 'Bearer token',
+ });
});
test(
@@ -479,64 +451,40 @@ void main() {
expect(uri.endsWith('/a.avi'), true, reason: 'Actual string: $uri');
}, skip: kIsWeb /* Web does not support file assets. */);
- test(
- 'file with special characters',
- () async {
- final controller = VideoPlayerController.file(File('A #1 Hit.avi'));
- await controller.initialize();
+ test('file with special characters', () async {
+ final controller = VideoPlayerController.file(File('A #1 Hit.avi'));
+ await controller.initialize();
- final String uri = fakeVideoPlayerPlatform.dataSources[0].uri!;
- expect(
- uri.startsWith('file:///'),
- true,
- reason: 'Actual string: $uri',
- );
- expect(
- uri.endsWith('/A%20%231%20Hit.avi'),
- true,
- reason: 'Actual string: $uri',
- );
- },
- skip: kIsWeb /* Web does not support file assets. */,
- );
+ final String uri = fakeVideoPlayerPlatform.dataSources[0].uri!;
+ expect(uri.startsWith('file:///'), true, reason: 'Actual string: $uri');
+ expect(uri.endsWith('/A%20%231%20Hit.avi'), true, reason: 'Actual string: $uri');
+ }, skip: kIsWeb /* Web does not support file assets. */);
- test(
- 'file with headers (m3u8)',
- () async {
- final controller = VideoPlayerController.file(
- File('a.avi'),
- httpHeaders: {'Authorization': 'Bearer token'},
- );
- await controller.initialize();
+ test('file with headers (m3u8)', () async {
+ final controller = VideoPlayerController.file(
+ File('a.avi'),
+ httpHeaders: {'Authorization': 'Bearer token'},
+ );
+ await controller.initialize();
- final String uri = fakeVideoPlayerPlatform.dataSources[0].uri!;
- expect(
- uri.startsWith('file:///'),
- true,
- reason: 'Actual string: $uri',
- );
- expect(uri.endsWith('/a.avi'), true, reason: 'Actual string: $uri');
+ final String uri = fakeVideoPlayerPlatform.dataSources[0].uri!;
+ expect(uri.startsWith('file:///'), true, reason: 'Actual string: $uri');
+ expect(uri.endsWith('/a.avi'), true, reason: 'Actual string: $uri');
- expect(
- fakeVideoPlayerPlatform.dataSources[0].httpHeaders,
- {'Authorization': 'Bearer token'},
- );
- },
- skip: kIsWeb /* Web does not support file assets. */,
- );
+ expect(fakeVideoPlayerPlatform.dataSources[0].httpHeaders, {
+ 'Authorization': 'Bearer token',
+ });
+ }, skip: kIsWeb /* Web does not support file assets. */);
- test(
- 'successful initialize on controller with error clears error',
- () async {
- final controller = VideoPlayerController.network('https://127.0.0.1');
- fakeVideoPlayerPlatform.forceInitError = true;
- await controller.initialize().catchError((dynamic e) {});
- expect(controller.value.hasError, equals(true));
- fakeVideoPlayerPlatform.forceInitError = false;
- await controller.initialize();
- expect(controller.value.hasError, equals(false));
- },
- );
+ test('successful initialize on controller with error clears error', () async {
+ final controller = VideoPlayerController.network('https://127.0.0.1');
+ fakeVideoPlayerPlatform.forceInitError = true;
+ await controller.initialize().catchError((dynamic e) {});
+ expect(controller.value.hasError, equals(true));
+ fakeVideoPlayerPlatform.forceInitError = false;
+ await controller.initialize();
+ expect(controller.value.hasError, equals(false));
+ });
test(
'given controller with error when initialization succeeds it should clear error',
@@ -555,9 +503,7 @@ void main() {
});
test('contentUri', () async {
- final controller = VideoPlayerController.contentUri(
- Uri.parse('content://video'),
- );
+ final controller = VideoPlayerController.contentUri(Uri.parse('content://video'));
await controller.initialize();
expect(fakeVideoPlayerPlatform.dataSources[0].uri, 'content://video');
@@ -588,9 +534,7 @@ void main() {
});
test('play', () async {
- final controller = VideoPlayerController.networkUrl(
- Uri.parse('https://127.0.0.1'),
- );
+ final controller = VideoPlayerController.networkUrl(Uri.parse('https://127.0.0.1'));
addTearDown(controller.dispose);
await controller.initialize();
@@ -755,22 +699,14 @@ void main() {
});
group('scrubbing', () {
- testWidgets('restarts on release if already playing', (
- WidgetTester tester,
- ) async {
+ testWidgets('restarts on release if already playing', (WidgetTester tester) async {
final controller = VideoPlayerController.networkUrl(_localhostUri);
await controller.initialize();
- final progressWidget = VideoProgressIndicator(
- controller,
- allowScrubbing: true,
- );
+ final progressWidget = VideoProgressIndicator(controller, allowScrubbing: true);
await tester.pumpWidget(
- Directionality(
- textDirection: TextDirection.ltr,
- child: progressWidget,
- ),
+ Directionality(textDirection: TextDirection.ltr, child: progressWidget),
);
await controller.play();
@@ -787,22 +723,14 @@ void main() {
await tester.runAsync(controller.dispose);
});
- testWidgets('does not restart when dragging to end', (
- WidgetTester tester,
- ) async {
+ testWidgets('does not restart when dragging to end', (WidgetTester tester) async {
final controller = VideoPlayerController.networkUrl(_localhostUri);
await controller.initialize();
- final progressWidget = VideoProgressIndicator(
- controller,
- allowScrubbing: true,
- );
+ final progressWidget = VideoProgressIndicator(controller, allowScrubbing: true);
await tester.pumpWidget(
- Directionality(
- textDirection: TextDirection.ltr,
- child: progressWidget,
- ),
+ Directionality(textDirection: TextDirection.ltr, child: progressWidget),
);
await controller.play();
@@ -1013,9 +941,7 @@ void main() {
final StreamController fakeVideoEventStream =
fakeVideoPlayerPlatform.streams[controller.playerId]!;
- fakeVideoEventStream.add(
- VideoEvent(eventType: VideoEventType.completed),
- );
+ fakeVideoEventStream.add(VideoEvent(eventType: VideoEventType.completed));
await tester.pumpAndSettle();
expect(controller.value.isPlaying, isFalse);
@@ -1031,19 +957,13 @@ void main() {
fakeVideoPlayerPlatform.streams[controller.playerId]!;
fakeVideoEventStream.add(
- VideoEvent(
- eventType: VideoEventType.isPlayingStateUpdate,
- isPlaying: true,
- ),
+ VideoEvent(eventType: VideoEventType.isPlayingStateUpdate, isPlaying: true),
);
await tester.pumpAndSettle();
expect(controller.value.isPlaying, isTrue);
fakeVideoEventStream.add(
- VideoEvent(
- eventType: VideoEventType.isPlayingStateUpdate,
- isPlaying: false,
- ),
+ VideoEvent(eventType: VideoEventType.isPlayingStateUpdate, isPlaying: false),
);
await tester.pumpAndSettle();
expect(controller.value.isPlaying, isFalse);
@@ -1059,9 +979,7 @@ void main() {
final StreamController fakeVideoEventStream =
fakeVideoPlayerPlatform.streams[controller.playerId]!;
- fakeVideoEventStream.add(
- VideoEvent(eventType: VideoEventType.bufferingStart),
- );
+ fakeVideoEventStream.add(VideoEvent(eventType: VideoEventType.bufferingStart));
await tester.pumpAndSettle();
expect(controller.value.isBuffering, isTrue);
@@ -1081,9 +999,7 @@ void main() {
DurationRange(bufferStart, bufferEnd).toString(),
);
- fakeVideoEventStream.add(
- VideoEvent(eventType: VideoEventType.bufferingEnd),
- );
+ fakeVideoEventStream.add(VideoEvent(eventType: VideoEventType.bufferingEnd));
await tester.pumpAndSettle();
expect(controller.value.isBuffering, isFalse);
await tester.runAsync(controller.dispose);
@@ -1263,17 +1179,13 @@ void main() {
});
test('errorDescription is changed when copy with another error', () {
const original = VideoPlayerValue.erroneous('error');
- final VideoPlayerValue copy = original.copyWith(
- errorDescription: 'new error',
- );
+ final VideoPlayerValue copy = original.copyWith(errorDescription: 'new error');
expect(copy.errorDescription, 'new error');
});
test('errorDescription is changed when copy with error', () {
const original = VideoPlayerValue.uninitialized();
- final VideoPlayerValue copy = original.copyWith(
- errorDescription: 'new error',
- );
+ final VideoPlayerValue copy = original.copyWith(errorDescription: 'new error');
expect(copy.errorDescription, 'new error');
});
@@ -1347,10 +1259,7 @@ void main() {
await controller.initialize();
await controller.play();
- verifyPlayStateRespondsToLifecycle(
- controller,
- shouldPlayInBackground: true,
- );
+ verifyPlayStateRespondsToLifecycle(controller, shouldPlayInBackground: true);
});
test('false allowBackgroundPlayback pauses playback', () async {
@@ -1362,10 +1271,7 @@ void main() {
await controller.initialize();
await controller.play();
- verifyPlayStateRespondsToLifecycle(
- controller,
- shouldPlayInBackground: false,
- );
+ verifyPlayStateRespondsToLifecycle(controller, shouldPlayInBackground: false);
});
});
@@ -1438,10 +1344,7 @@ void main() {
isCompletedTest();
if (!hasLooped) {
fakeVideoEventStream.add(
- VideoEvent(
- eventType: VideoEventType.isPlayingStateUpdate,
- isPlaying: true,
- ),
+ VideoEvent(eventType: VideoEventType.isPlayingStateUpdate, isPlaying: true),
);
hasLooped = !hasLooped;
}
@@ -1467,9 +1370,7 @@ void main() {
final void Function() isCompletedTest = expectAsync0(() {});
- controller.value = controller.value.copyWith(
- duration: const Duration(seconds: 10),
- );
+ controller.value = controller.value.copyWith(duration: const Duration(seconds: 10));
controller.addListener(() async {
if (currentIsCompleted != controller.value.isCompleted) {
@@ -1486,6 +1387,91 @@ 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 {
@@ -1498,8 +1484,7 @@ class FakeVideoPlayerPlatform extends VideoPlayerPlatform {
bool forceInitError = false;
int nextPlayerId = 0;
final Map _positions = {};
- final Map webOptions =
- {};
+ final Map webOptions = {};
@override
Future create(DataSource dataSource) async {
@@ -1508,10 +1493,7 @@ class FakeVideoPlayerPlatform extends VideoPlayerPlatform {
streams[nextPlayerId] = stream;
if (forceInitError) {
stream.addError(
- PlatformException(
- code: 'VideoError',
- message: 'Video player had error XYZ',
- ),
+ PlatformException(code: 'VideoError', message: 'Video player had error XYZ'),
);
} else {
stream.add(
@@ -1533,10 +1515,7 @@ class FakeVideoPlayerPlatform extends VideoPlayerPlatform {
streams[nextPlayerId] = stream;
if (forceInitError) {
stream.addError(
- PlatformException(
- code: 'VideoError',
- message: 'Video player had error XYZ',
- ),
+ PlatformException(code: 'VideoError', message: 'Video player had error XYZ'),
);
} else {
stream.add(
@@ -1616,14 +1595,37 @@ class FakeVideoPlayerPlatform extends VideoPlayerPlatform {
}
@override
- Future setWebOptions(
- int playerId,
- VideoPlayerWebOptions options,
- ) async {
+ Future setWebOptions(int playerId, VideoPlayerWebOptions options) async {
if (!kIsWeb) {
throw UnimplementedError('setWebOptions() is only available in the web.');
}
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 eeaf1f1c0bd..4d53b04bd9d 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.1
* Updates to Pigeon 26.1.5.
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..802272c13e3 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
@@ -17,9 +17,7 @@ VideoPlayerInstanceApi _productionApiProvider(int playerId) {
}
/// The non-test implementation of `_videoEventStreamProvider`.
-Stream _productionVideoEventStreamProvider(
- String streamIdentifier,
-) {
+Stream _productionVideoEventStreamProvider(String streamIdentifier) {
return pigeon.videoEvents(instanceName: streamIdentifier);
}
@@ -29,8 +27,7 @@ class AndroidVideoPlayer extends VideoPlayerPlatform {
/// Creates a new Android video player implementation instance.
AndroidVideoPlayer({
@visibleForTesting AndroidVideoPlayerApi? pluginApi,
- @visibleForTesting
- VideoPlayerInstanceApi Function(int playerId)? playerApiProvider,
+ @visibleForTesting VideoPlayerInstanceApi Function(int playerId)? playerApiProvider,
Stream Function(String streamIdentifier)?
videoEventStreamProvider,
}) : _api = pluginApi ?? AndroidVideoPlayerApi(),
@@ -90,14 +87,9 @@ class AndroidVideoPlayer extends VideoPlayerPlatform {
case DataSourceType.asset:
final String? asset = dataSource.asset;
if (asset == null) {
- throw ArgumentError(
- '"asset" must be non-null for an asset data source',
- );
+ throw ArgumentError('"asset" must be non-null for an asset data source');
}
- final String key = await _api.getLookupKeyForAsset(
- asset,
- dataSource.package,
- );
+ final String key = await _api.getLookupKeyForAsset(asset, dataSource.package);
uri = 'asset:///$key';
case DataSourceType.network:
uri = dataSource.uri;
@@ -213,9 +205,7 @@ class AndroidVideoPlayer extends VideoPlayerPlatform {
final VideoPlayerViewState viewState = _playerWith(id: playerId).viewState;
return switch (viewState) {
- VideoPlayerTextureViewState(:final int textureId) => Texture(
- textureId: textureId,
- ),
+ VideoPlayerTextureViewState(:final int textureId) => Texture(textureId: textureId),
VideoPlayerPlatformViewState() => PlatformViewPlayer(playerId: playerId),
};
}
@@ -266,14 +256,57 @@ 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.'));
}
- PlatformVideoFormat? _platformVideoFormatFromVideoFormat(
- VideoFormat? format,
- ) {
+ PlatformVideoFormat? _platformVideoFormatFromVideoFormat(VideoFormat? format) {
return switch (format) {
VideoFormat.dash => PlatformVideoFormat.dash,
VideoFormat.hls => PlatformVideoFormat.hls,
@@ -314,6 +347,8 @@ class _PlayerInstance {
int _lastBufferPosition = -1;
bool _isBuffering = false;
Completer? _audioTrackSelectionCompleter;
+ Completer? _videoTrackSelectionCompleter;
+ String? _expectedVideoTrackId;
final VideoPlayerViewState viewState;
@@ -384,6 +419,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();
@@ -468,9 +560,7 @@ class _PlayerInstance {
// should be synchronous with the state change.
break;
case PlatformPlaybackState.ended:
- _eventStreamController.add(
- VideoEvent(eventType: VideoEventType.completed),
- );
+ _eventStreamController.add(VideoEvent(eventType: VideoEventType.completed));
case PlatformPlaybackState.unknown:
// Ignore unknown states. This isn't an error since the media
// framework could add new states in the future.
@@ -487,6 +577,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