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 _toList() { + return [selectedTrackId]; + } + + Object encode() { + return _toList(); + } + + static VideoTrackChangedEvent decode(Object result) { + result as List; + return VideoTrackChangedEvent(selectedTrackId: result[0] as String?); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! VideoTrackChangedEvent || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()); +} + /// Information passed to the platform view creation. class PlatformVideoViewCreationParams { PlatformVideoViewCreationParams({required this.playerId}); @@ -588,6 +629,128 @@ class NativeAudioTrackData { int get hashCode => Object.hashAll(_toList()); } +/// Raw video track data from ExoPlayer Format objects. +class ExoPlayerVideoTrackData { + ExoPlayerVideoTrackData({ + required this.groupIndex, + required this.trackIndex, + this.label, + required this.isSelected, + this.bitrate, + this.width, + this.height, + this.frameRate, + this.codec, + }); + + int groupIndex; + + int trackIndex; + + String? label; + + bool isSelected; + + int? bitrate; + + int? width; + + int? height; + + double? frameRate; + + String? codec; + + List _toList() { + return [ + groupIndex, + trackIndex, + label, + isSelected, + bitrate, + width, + height, + frameRate, + codec, + ]; + } + + Object encode() { + return _toList(); + } + + static ExoPlayerVideoTrackData decode(Object result) { + result as List; + return ExoPlayerVideoTrackData( + groupIndex: result[0]! as int, + trackIndex: result[1]! as int, + label: result[2] as String?, + isSelected: result[3]! as bool, + bitrate: result[4] as int?, + width: result[5] as int?, + height: result[6] as int?, + frameRate: result[7] as double?, + codec: result[8] as String?, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! ExoPlayerVideoTrackData || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()); +} + +/// Container for raw video track data from Android ExoPlayer. +class NativeVideoTrackData { + NativeVideoTrackData({this.exoPlayerTracks}); + + /// ExoPlayer-based tracks + List? exoPlayerTracks; + + List _toList() { + return [exoPlayerTracks]; + } + + Object encode() { + return _toList(); + } + + static NativeVideoTrackData decode(Object result) { + result as List; + return NativeVideoTrackData( + exoPlayerTracks: (result[0] as List?) + ?.cast(), + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! NativeVideoTrackData || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()); +} + class _PigeonCodec extends StandardMessageCodec { const _PigeonCodec(); @override @@ -613,27 +776,36 @@ class _PigeonCodec extends StandardMessageCodec { } else if (value is AudioTrackChangedEvent) { buffer.putUint8(134); writeValue(buffer, value.encode()); - } else if (value is PlatformVideoViewCreationParams) { + } else if (value is VideoTrackChangedEvent) { buffer.putUint8(135); writeValue(buffer, value.encode()); - } else if (value is CreationOptions) { + } else if (value is PlatformVideoViewCreationParams) { buffer.putUint8(136); writeValue(buffer, value.encode()); - } else if (value is TexturePlayerIds) { + } else if (value is CreationOptions) { buffer.putUint8(137); writeValue(buffer, value.encode()); - } else if (value is PlaybackState) { + } else if (value is TexturePlayerIds) { buffer.putUint8(138); writeValue(buffer, value.encode()); - } else if (value is AudioTrackMessage) { + } else if (value is PlaybackState) { buffer.putUint8(139); writeValue(buffer, value.encode()); - } else if (value is ExoPlayerAudioTrackData) { + } else if (value is AudioTrackMessage) { buffer.putUint8(140); writeValue(buffer, value.encode()); - } else if (value is NativeAudioTrackData) { + } else if (value is ExoPlayerAudioTrackData) { buffer.putUint8(141); writeValue(buffer, value.encode()); + } else if (value is NativeAudioTrackData) { + buffer.putUint8(142); + writeValue(buffer, value.encode()); + } else if (value is ExoPlayerVideoTrackData) { + buffer.putUint8(143); + writeValue(buffer, value.encode()); + } else if (value is NativeVideoTrackData) { + buffer.putUint8(144); + writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); } @@ -657,19 +829,25 @@ class _PigeonCodec extends StandardMessageCodec { case 134: return AudioTrackChangedEvent.decode(readValue(buffer)!); case 135: - return PlatformVideoViewCreationParams.decode(readValue(buffer)!); + return VideoTrackChangedEvent.decode(readValue(buffer)!); case 136: - return CreationOptions.decode(readValue(buffer)!); + return PlatformVideoViewCreationParams.decode(readValue(buffer)!); case 137: - return TexturePlayerIds.decode(readValue(buffer)!); + return CreationOptions.decode(readValue(buffer)!); case 138: - return PlaybackState.decode(readValue(buffer)!); + return TexturePlayerIds.decode(readValue(buffer)!); case 139: - return AudioTrackMessage.decode(readValue(buffer)!); + return PlaybackState.decode(readValue(buffer)!); case 140: - return ExoPlayerAudioTrackData.decode(readValue(buffer)!); + return AudioTrackMessage.decode(readValue(buffer)!); case 141: + return ExoPlayerAudioTrackData.decode(readValue(buffer)!); + case 142: return NativeAudioTrackData.decode(readValue(buffer)!); + case 143: + return ExoPlayerVideoTrackData.decode(readValue(buffer)!); + case 144: + return NativeVideoTrackData.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); } @@ -1142,6 +1320,62 @@ class VideoPlayerInstanceApi { return; } } + + /// Gets the available video tracks for the video. + Future getVideoTracks() async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.video_player_android.VideoPlayerInstanceApi.getVideoTracks$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as NativeVideoTrackData?)!; + } + } + + /// Selects which video track is chosen for playback from its [groupIndex] and [trackIndex]. + /// Pass -1 for both indices to enable auto quality selection. + Future selectVideoTrack(int groupIndex, int trackIndex) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.video_player_android.VideoPlayerInstanceApi.selectVideoTrack$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [groupIndex, trackIndex], + ); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } } Stream videoEvents({String instanceName = ''}) { diff --git a/packages/video_player/video_player_android/pigeons/messages.dart b/packages/video_player/video_player_android/pigeons/messages.dart index 8666b074969..07323dbc114 100644 --- a/packages/video_player/video_player_android/pigeons/messages.dart +++ b/packages/video_player/video_player_android/pigeons/messages.dart @@ -60,6 +60,16 @@ class AudioTrackChangedEvent extends PlatformVideoEvent { late final String? selectedTrackId; } +/// 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 { + /// The ID of the newly selected video track, if any. + /// Will be null when auto quality selection is enabled. + late final String? selectedTrackId; +} + /// Information passed to the platform view creation. class PlatformVideoViewCreationParams { const PlatformVideoViewCreationParams({required this.playerId}); @@ -148,6 +158,39 @@ class NativeAudioTrackData { List? exoPlayerTracks; } +/// Raw video track data from ExoPlayer Format objects. +class ExoPlayerVideoTrackData { + ExoPlayerVideoTrackData({ + required this.groupIndex, + required this.trackIndex, + this.label, + required this.isSelected, + this.bitrate, + this.width, + this.height, + this.frameRate, + this.codec, + }); + + int groupIndex; + int trackIndex; + String? label; + bool isSelected; + int? bitrate; + int? width; + int? height; + double? frameRate; + String? codec; +} + +/// Container for raw video track data from Android ExoPlayer. +class NativeVideoTrackData { + NativeVideoTrackData({this.exoPlayerTracks}); + + /// ExoPlayer-based tracks + List? exoPlayerTracks; +} + @HostApi() abstract class AndroidVideoPlayerApi { void initialize(); @@ -192,6 +235,13 @@ abstract class VideoPlayerInstanceApi { /// Selects which audio track is chosen for playback from its [groupIndex] and [trackIndex] void selectAudioTrack(int groupIndex, int trackIndex); + + /// Gets the available video tracks for the video. + NativeVideoTrackData getVideoTracks(); + + /// Selects which video track is chosen for playback from its [groupIndex] and [trackIndex]. + /// Pass -1 for both indices to enable auto quality selection. + void selectVideoTrack(int groupIndex, int trackIndex); } @EventChannelApi() diff --git a/packages/video_player/video_player_android/pubspec.yaml b/packages/video_player/video_player_android/pubspec.yaml index dccca416899..818f4039bf1 100644 --- a/packages/video_player/video_player_android/pubspec.yaml +++ b/packages/video_player/video_player_android/pubspec.yaml @@ -20,7 +20,8 @@ flutter: dependencies: flutter: sdk: flutter - video_player_platform_interface: ^6.6.0 + video_player_platform_interface: + path: ../video_player_platform_interface dev_dependencies: build_runner: ^2.3.3 diff --git a/packages/video_player/video_player_android/test/android_video_player_test.dart b/packages/video_player/video_player_android/test/android_video_player_test.dart index 810a815fddf..fad08644da1 100644 --- a/packages/video_player/video_player_android/test/android_video_player_test.dart +++ b/packages/video_player/video_player_android/test/android_video_player_test.dart @@ -31,6 +31,15 @@ void main() { NativeAudioTrackData(exoPlayerTracks: []), ), ); + // Provide dummy values for video track types + provideDummy( + NativeVideoTrackData(exoPlayerTracks: []), + ); + provideDummy>( + Future.value( + NativeVideoTrackData(exoPlayerTracks: []), + ), + ); provideDummy>(Future.value()); (AndroidVideoPlayer, MockAndroidVideoPlayerApi, MockVideoPlayerInstanceApi) @@ -950,5 +959,160 @@ void main() { verify(api.selectAudioTrack(0, 1)); }); }); + + group('video tracks', () { + test('isVideoTrackSupportAvailable returns true', () { + final (AndroidVideoPlayer player, _, _) = setUpMockPlayer(playerId: 1); + + expect(player.isVideoTrackSupportAvailable(), true); + }); + + test('getVideoTracks returns empty list when no tracks', () async { + final (AndroidVideoPlayer player, _, MockVideoPlayerInstanceApi api) = + setUpMockPlayer(playerId: 1); + when( + api.getVideoTracks(), + ).thenAnswer((_) async => NativeVideoTrackData()); + + final List tracks = await player.getVideoTracks(1); + + expect(tracks, isEmpty); + }); + + test('getVideoTracks converts native tracks to VideoTrack', () async { + final (AndroidVideoPlayer player, _, MockVideoPlayerInstanceApi api) = + setUpMockPlayer(playerId: 1); + when(api.getVideoTracks()).thenAnswer( + (_) async => NativeVideoTrackData( + exoPlayerTracks: [ + ExoPlayerVideoTrackData( + groupIndex: 0, + trackIndex: 0, + label: '1080p', + isSelected: true, + bitrate: 5000000, + width: 1920, + height: 1080, + frameRate: 30.0, + codec: 'avc1.64001f', + ), + ExoPlayerVideoTrackData( + groupIndex: 0, + trackIndex: 1, + label: '720p', + isSelected: false, + bitrate: 2500000, + width: 1280, + height: 720, + frameRate: 30.0, + codec: 'avc1.64001f', + ), + ], + ), + ); + + final List tracks = await player.getVideoTracks(1); + + 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[0].width, 1920); + expect(tracks[0].height, 1080); + expect(tracks[0].frameRate, 30.0); + expect(tracks[0].codec, 'avc1.64001f'); + + expect(tracks[1].id, '0_1'); + expect(tracks[1].label, '720p'); + expect(tracks[1].isSelected, false); + expect(tracks[1].bitrate, 2500000); + expect(tracks[1].width, 1280); + expect(tracks[1].height, 720); + }); + + test( + 'getVideoTracks generates label from resolution if not provided', + () async { + final (AndroidVideoPlayer player, _, MockVideoPlayerInstanceApi api) = + setUpMockPlayer(playerId: 1); + when(api.getVideoTracks()).thenAnswer( + (_) async => NativeVideoTrackData( + exoPlayerTracks: [ + ExoPlayerVideoTrackData( + groupIndex: 0, + trackIndex: 0, + isSelected: true, + width: 1920, + height: 1080, + ), + ], + ), + ); + + final List tracks = await player.getVideoTracks(1); + + expect(tracks.length, 1); + expect(tracks[0].label, '1080p'); + }, + ); + + test('getVideoTracks handles null exoPlayerTracks', () async { + final (AndroidVideoPlayer player, _, MockVideoPlayerInstanceApi api) = + setUpMockPlayer(playerId: 1); + when( + api.getVideoTracks(), + ).thenAnswer((_) async => NativeVideoTrackData()); + + final List tracks = await player.getVideoTracks(1); + + expect(tracks, isEmpty); + }); + + test( + 'selectVideoTrack with null clears override (auto quality)', + () async { + final (AndroidVideoPlayer player, _, MockVideoPlayerInstanceApi api) = + setUpMockPlayer(playerId: 1); + when(api.selectVideoTrack(-1, -1)).thenAnswer((_) async {}); + + await player.selectVideoTrack(1, null); + + verify(api.selectVideoTrack(-1, -1)); + }, + ); + + test('selectVideoTrack parses track id and calls API', () async { + final (AndroidVideoPlayer player, _, MockVideoPlayerInstanceApi api) = + setUpMockPlayer(playerId: 1); + when(api.selectVideoTrack(0, 2)).thenAnswer((_) async {}); + + const track = VideoTrack(id: '0_2', isSelected: false); + await player.selectVideoTrack(1, track); + + verify(api.selectVideoTrack(0, 2)); + }); + + test('selectVideoTrack throws on invalid track id format', () async { + final (AndroidVideoPlayer player, _, _) = setUpMockPlayer(playerId: 1); + + const track = VideoTrack(id: 'invalid', isSelected: false); + expect( + () => player.selectVideoTrack(1, track), + throwsA(isA()), + ); + }); + + test('selectVideoTrack throws on track id with too many parts', () async { + final (AndroidVideoPlayer player, _, _) = setUpMockPlayer(playerId: 1); + + const track = VideoTrack(id: '1_2_3', isSelected: false); + expect( + () => player.selectVideoTrack(1, track), + throwsA(isA()), + ); + }); + }); }); } diff --git a/packages/video_player/video_player_android/test/android_video_player_test.mocks.dart b/packages/video_player/video_player_android/test/android_video_player_test.mocks.dart index 212c9bde40c..b3056d1ba71 100644 --- a/packages/video_player/video_player_android/test/android_video_player_test.mocks.dart +++ b/packages/video_player/video_player_android/test/android_video_player_test.mocks.dart @@ -22,6 +22,7 @@ import 'package:video_player_android/src/messages.g.dart' as _i2; // ignore_for_file: unnecessary_parenthesis // ignore_for_file: camel_case_types // ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member class _FakeTexturePlayerIds_0 extends _i1.SmartFake implements _i2.TexturePlayerIds { @@ -35,6 +36,12 @@ class _FakeNativeAudioTrackData_1 extends _i1.SmartFake : super(parent, parentInvocation); } +class _FakeNativeVideoTrackData_2 extends _i1.SmartFake + implements _i2.NativeVideoTrackData { + _FakeNativeVideoTrackData_2(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + /// A class which mocks [AndroidVideoPlayerApi]. /// /// See the documentation for Mockito's code generation for more information. @@ -252,4 +259,33 @@ class MockVideoPlayerInstanceApi extends _i1.Mock returnValueForMissingStub: _i4.Future.value(), ) as _i4.Future); + + @override + _i4.Future<_i2.NativeVideoTrackData> getVideoTracks() => + (super.noSuchMethod( + Invocation.method(#getVideoTracks, []), + returnValue: _i4.Future<_i2.NativeVideoTrackData>.value( + _FakeNativeVideoTrackData_2( + this, + Invocation.method(#getVideoTracks, []), + ), + ), + returnValueForMissingStub: + _i4.Future<_i2.NativeVideoTrackData>.value( + _FakeNativeVideoTrackData_2( + this, + Invocation.method(#getVideoTracks, []), + ), + ), + ) + as _i4.Future<_i2.NativeVideoTrackData>); + + @override + _i4.Future selectVideoTrack(int? groupIndex, int? trackIndex) => + (super.noSuchMethod( + Invocation.method(#selectVideoTrack, [groupIndex, trackIndex]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); } diff --git a/packages/video_player/video_player_avfoundation/CHANGELOG.md b/packages/video_player/video_player_avfoundation/CHANGELOG.md index 7f509fcc462..36986375855 100644 --- a/packages/video_player/video_player_avfoundation/CHANGELOG.md +++ b/packages/video_player/video_player_avfoundation/CHANGELOG.md @@ -1,3 +1,8 @@ +## NEXT + +* Implements `getVideoTracks()` and `selectVideoTrack()` methods for video track (quality) selection using AVFoundation. +* Video track selection requires iOS 15+ / macOS 12+ for HLS streams. + ## 2.8.8 * Refactors Dart internals for maintainability. diff --git a/packages/video_player/video_player_avfoundation/darwin/RunnerTests/VideoPlayerTests.m b/packages/video_player/video_player_avfoundation/darwin/RunnerTests/VideoPlayerTests.m index b095dbf33ae..5c3d096b01d 100644 --- a/packages/video_player/video_player_avfoundation/darwin/RunnerTests/VideoPlayerTests.m +++ b/packages/video_player/video_player_avfoundation/darwin/RunnerTests/VideoPlayerTests.m @@ -1051,4 +1051,226 @@ - (nonnull AVPlayerItem *)playerItemWithURL:(NSURL *)url { return [AVPlayerItem playerItemWithAsset:[AVURLAsset URLAssetWithURL:url options:nil]]; } +#pragma mark - Video Track Tests + +// Tests getVideoTracks with a regular MP4 video file using real AVFoundation. +// Regular MP4 files don't have HLS variants, so we expect empty media selection tracks. +- (void)testGetVideoTracksWithRealMP4Video { + FVPVideoPlayer *player = + [[FVPVideoPlayer alloc] initWithPlayerItem:[self playerItemWithURL:self.mp4TestURL] + avFactory:[[FVPDefaultAVFactory alloc] init] + viewProvider:[[StubViewProvider alloc] initWithView:nil]]; + XCTAssertNotNil(player); + + XCTestExpectation *initializedExpectation = [self expectationWithDescription:@"initialized"]; + StubEventListener *listener = + [[StubEventListener alloc] initWithInitializationExpectation:initializedExpectation]; + player.eventListener = listener; + [self waitForExpectationsWithTimeout:30.0 handler:nil]; + + // Now test getVideoTracks + XCTestExpectation *tracksExpectation = + [self expectationWithDescription:@"getVideoTracks completes"]; + [player + getVideoTracks:^(FVPNativeVideoTrackData *_Nullable result, FlutterError *_Nullable error) { + XCTAssertNil(error); + XCTAssertNotNil(result); + // For regular MP4 files, media selection tracks should be nil (no HLS variants) + // The method returns empty data for non-HLS content + [tracksExpectation fulfill]; + }]; + + [self waitForExpectationsWithTimeout:30.0 handler:nil]; + + FlutterError *disposeError; + [player disposeWithError:&disposeError]; +} + +// Tests getVideoTracks with an HLS stream using real AVFoundation. +// HLS streams use AVAssetVariant API (iOS 15+) for video track selection. +- (void)testGetVideoTracksWithRealHLSStream { + NSURL *hlsURL = [NSURL + URLWithString:@"https://flutter.github.io/assets-for-api-docs/assets/videos/hls/bee.m3u8"]; + XCTAssertNotNil(hlsURL); + + FVPVideoPlayer *player = + [[FVPVideoPlayer alloc] initWithPlayerItem:[self playerItemWithURL:hlsURL] + avFactory:[[FVPDefaultAVFactory alloc] init] + viewProvider:[[StubViewProvider alloc] initWithView:nil]]; + XCTAssertNotNil(player); + + XCTestExpectation *initializedExpectation = [self expectationWithDescription:@"initialized"]; + StubEventListener *listener = + [[StubEventListener alloc] initWithInitializationExpectation:initializedExpectation]; + player.eventListener = listener; + [self waitForExpectationsWithTimeout:30.0 handler:nil]; + + // Now test getVideoTracks + XCTestExpectation *tracksExpectation = + [self expectationWithDescription:@"getVideoTracks completes"]; + [player + getVideoTracks:^(FVPNativeVideoTrackData *_Nullable result, FlutterError *_Nullable error) { + XCTAssertNil(error); + XCTAssertNotNil(result); + + // For HLS streams on iOS 15+, we may have media selection tracks (variants) + if (@available(iOS 15.0, macOS 12.0, *)) { + // The bee.m3u8 stream may or may not have multiple video variants. + // We verify the method returns valid data without crashing. + if (result.mediaSelectionTracks) { + // If media selection tracks exist, they should have valid structure + for (FVPMediaSelectionVideoTrackData *track in result.mediaSelectionTracks) { + XCTAssertGreaterThanOrEqual(track.variantIndex, 0); + // Bitrate should be positive if present + if (track.bitrate) { + XCTAssertGreaterThan(track.bitrate.integerValue, 0); + } + } + } + } + [tracksExpectation fulfill]; + }]; + + [self waitForExpectationsWithTimeout:30.0 handler:nil]; + + FlutterError *disposeError; + [player disposeWithError:&disposeError]; +} + +// Tests selectVideoTrack sets preferredPeakBitRate correctly. +- (void)testSelectVideoTrackSetsBitrate { + FVPVideoPlayer *player = + [[FVPVideoPlayer alloc] initWithPlayerItem:[self playerItemWithURL:self.mp4TestURL] + avFactory:[[FVPDefaultAVFactory alloc] init] + viewProvider:[[StubViewProvider alloc] initWithView:nil]]; + XCTAssertNotNil(player); + + XCTestExpectation *initializedExpectation = [self expectationWithDescription:@"initialized"]; + StubEventListener *listener = + [[StubEventListener alloc] initWithInitializationExpectation:initializedExpectation]; + player.eventListener = listener; + [self waitForExpectationsWithTimeout:30.0 handler:nil]; + + FlutterError *error; + // Set a specific bitrate + [player selectVideoTrackWithBitrate:5000000 error:&error]; + XCTAssertNil(error); + XCTAssertEqual(player.player.currentItem.preferredPeakBitRate, 5000000); + + [player disposeWithError:&error]; +} + +// Tests selectVideoTrack with 0 bitrate enables auto quality selection. +- (void)testSelectVideoTrackAutoQuality { + FVPVideoPlayer *player = + [[FVPVideoPlayer alloc] initWithPlayerItem:[self playerItemWithURL:self.mp4TestURL] + avFactory:[[FVPDefaultAVFactory alloc] init] + viewProvider:[[StubViewProvider alloc] initWithView:nil]]; + XCTAssertNotNil(player); + + XCTestExpectation *initializedExpectation = [self expectationWithDescription:@"initialized"]; + StubEventListener *listener = + [[StubEventListener alloc] initWithInitializationExpectation:initializedExpectation]; + player.eventListener = listener; + [self waitForExpectationsWithTimeout:30.0 handler:nil]; + + FlutterError *error; + // First set a specific bitrate + [player selectVideoTrackWithBitrate:5000000 error:&error]; + XCTAssertNil(error); + XCTAssertEqual(player.player.currentItem.preferredPeakBitRate, 5000000); + + // Then set to auto quality (0) + [player selectVideoTrackWithBitrate:0 error:&error]; + XCTAssertNil(error); + XCTAssertEqual(player.player.currentItem.preferredPeakBitRate, 0); + + [player disposeWithError:&error]; +} + +// Tests that getVideoTracks works correctly through the plugin API with a real video. +- (void)testGetVideoTracksViaPluginWithRealVideo { + NSObject *registrar = OCMProtocolMock(@protocol(FlutterPluginRegistrar)); + FVPVideoPlayerPlugin *videoPlayerPlugin = + [[FVPVideoPlayerPlugin alloc] initWithRegistrar:registrar]; + + FlutterError *error; + [videoPlayerPlugin initialize:&error]; + XCTAssertNil(error); + + FVPCreationOptions *create = [FVPCreationOptions + makeWithUri:@"https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4" + httpHeaders:@{}]; + FVPTexturePlayerIds *identifiers = [videoPlayerPlugin createTexturePlayerWithOptions:create + error:&error]; + XCTAssertNil(error); + XCTAssertNotNil(identifiers); + + FVPVideoPlayer *player = videoPlayerPlugin.playersByIdentifier[@(identifiers.playerId)]; + XCTAssertNotNil(player); + + // Wait for player item to become ready + AVPlayerItem *item = player.player.currentItem; + [self keyValueObservingExpectationForObject:(id)item + keyPath:@"status" + expectedValue:@(AVPlayerItemStatusReadyToPlay)]; + [self waitForExpectationsWithTimeout:30.0 handler:nil]; + + // Now test getVideoTracks + XCTestExpectation *tracksExpectation = + [self expectationWithDescription:@"getVideoTracks completes"]; + [player + getVideoTracks:^(FVPNativeVideoTrackData *_Nullable result, FlutterError *_Nullable error) { + XCTAssertNil(error); + XCTAssertNotNil(result); + [tracksExpectation fulfill]; + }]; + + [self waitForExpectationsWithTimeout:30.0 handler:nil]; + + [player disposeWithError:&error]; +} + +// Tests selectVideoTrack via plugin API with HLS stream. +- (void)testSelectVideoTrackViaPluginWithHLSStream { + NSObject *registrar = OCMProtocolMock(@protocol(FlutterPluginRegistrar)); + FVPVideoPlayerPlugin *videoPlayerPlugin = + [[FVPVideoPlayerPlugin alloc] initWithRegistrar:registrar]; + + FlutterError *error; + [videoPlayerPlugin initialize:&error]; + XCTAssertNil(error); + + // Use HLS stream which supports adaptive bitrate + FVPCreationOptions *create = [FVPCreationOptions + makeWithUri:@"https://flutter.github.io/assets-for-api-docs/assets/videos/hls/bee.m3u8" + httpHeaders:@{}]; + FVPTexturePlayerIds *identifiers = [videoPlayerPlugin createTexturePlayerWithOptions:create + error:&error]; + XCTAssertNil(error); + XCTAssertNotNil(identifiers); + + FVPVideoPlayer *player = videoPlayerPlugin.playersByIdentifier[@(identifiers.playerId)]; + XCTAssertNotNil(player); + + // Wait for player item to become ready + AVPlayerItem *item = player.player.currentItem; + [self keyValueObservingExpectationForObject:(id)item + keyPath:@"status" + expectedValue:@(AVPlayerItemStatusReadyToPlay)]; + [self waitForExpectationsWithTimeout:30.0 handler:nil]; + + // Test setting a specific bitrate + [player selectVideoTrackWithBitrate:1000000 error:&error]; + XCTAssertNil(error); + XCTAssertEqual(player.player.currentItem.preferredPeakBitRate, 1000000); + + // Test setting auto quality + [player selectVideoTrackWithBitrate:0 error:&error]; + XCTAssertNil(error); + XCTAssertEqual(player.player.currentItem.preferredPeakBitRate, 0); + + [player disposeWithError:&error]; +} + @end diff --git a/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/FVPVideoPlayer.m b/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/FVPVideoPlayer.m index 9da957fbc8c..061408dc3b3 100644 --- a/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/FVPVideoPlayer.m +++ b/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/FVPVideoPlayer.m @@ -5,6 +5,7 @@ #import "./include/video_player_avfoundation/FVPVideoPlayer.h" #import "./include/video_player_avfoundation/FVPVideoPlayer_Internal.h" +#import #import #import "./include/video_player_avfoundation/AVAssetTrackUtils.h" @@ -421,6 +422,130 @@ - (void)setPlaybackSpeed:(double)speed error:(FlutterError *_Nullable *_Nonnull) [self updatePlayingState]; } +- (void)getVideoTracks:(void (^)(FVPNativeVideoTrackData *_Nullable, + FlutterError *_Nullable))completion { + NSMutableArray *mediaSelectionTracks = [NSMutableArray array]; + + AVPlayerItem *currentItem = _player.currentItem; + if (!currentItem) { + completion([[FVPNativeVideoTrackData alloc] init], nil); + return; + } + + AVURLAsset *urlAsset = (AVURLAsset *)currentItem.asset; + if (![urlAsset isKindOfClass:[AVURLAsset class]]) { + completion([[FVPNativeVideoTrackData alloc] init], nil); + return; + } + + // Use AVAssetVariant API for iOS 15+ to get HLS variants + if (@available(iOS 15.0, macOS 12.0, *)) { + [urlAsset + loadValuesAsynchronouslyForKeys:@[ @"variants" ] + completionHandler:^{ + dispatch_async(dispatch_get_main_queue(), ^{ + NSError *error = nil; + AVKeyValueStatus status = [urlAsset statusOfValueForKey:@"variants" + error:&error]; + + if (status == AVKeyValueStatusLoaded) { + NSArray *variants = urlAsset.variants; + double currentBitrate = currentItem.preferredPeakBitRate > 0 + ? currentItem.preferredPeakBitRate + : 0; + + for (NSInteger i = 0; i < variants.count; i++) { + AVAssetVariant *variant = variants[i]; + double peakBitRate = variant.peakBitRate; + CGSize videoSize = CGSizeZero; + double frameRate = 0; + NSString *codec = nil; + + // Get video attributes if available + AVAssetVariantVideoAttributes *videoAttrs = variant.videoAttributes; + if (videoAttrs) { + videoSize = videoAttrs.presentationSize; + frameRate = videoAttrs.nominalFrameRate; + // Get codec from media sub types + NSArray *codecTypes = videoAttrs.codecTypes; + if (codecTypes.count > 0) { + FourCharCode codecType = [codecTypes[0] unsignedIntValue]; + codec = [self codecStringFromFourCharCode:codecType]; + } + } + + // Determine if this variant is selected (approximate match by + // bitrate) + BOOL isSelected = + (currentBitrate > 0 && + fabs(peakBitRate - currentBitrate) < peakBitRate * 0.1); + + // Generate label from resolution + NSString *label = nil; + if (videoSize.height > 0) { + label = [NSString stringWithFormat:@"%.0fp", videoSize.height]; + } + + FVPMediaSelectionVideoTrackData *trackData = + [FVPMediaSelectionVideoTrackData + makeWithVariantIndex:i + label:label + bitrate:peakBitRate > 0 + ? @((NSInteger)peakBitRate) + : nil + width:videoSize.width > 0 + ? @((NSInteger)videoSize.width) + : nil + height:videoSize.height > 0 + ? @((NSInteger)videoSize.height) + : nil + frameRate:frameRate > 0 ? @(frameRate) : nil + codec:codec + isSelected:isSelected]; + [mediaSelectionTracks addObject:trackData]; + } + } + + FVPNativeVideoTrackData *result = [FVPNativeVideoTrackData + makeWithAssetTracks:nil + mediaSelectionTracks:mediaSelectionTracks.count > 0 + ? mediaSelectionTracks + : nil]; + completion(result, nil); + }); + }]; + } else { + // For iOS < 15, return empty list as AVAssetVariant is not available + completion([[FVPNativeVideoTrackData alloc] init], nil); + } +} + +- (NSString *)codecStringFromFourCharCode:(FourCharCode)code { + // Convert common video codec FourCharCodes to readable strings + switch (code) { + case kCMVideoCodecType_H264: + return @"avc1"; + case kCMVideoCodecType_HEVC: + return @"hevc"; + case kCMVideoCodecType_VP9: + return @"vp9"; + default: + return nil; + } +} + +- (void)selectVideoTrackWithBitrate:(NSInteger)bitrate + error:(FlutterError *_Nullable *_Nonnull)error { + AVPlayerItem *currentItem = _player.currentItem; + if (!currentItem) { + return; + } + + // Set preferredPeakBitRate to select the quality + // 0 means auto quality (adaptive streaming) + currentItem.preferredPeakBitRate = (double)bitrate; +} + #pragma mark - Private - (int64_t)duration { diff --git a/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/include/video_player_avfoundation/messages.g.h b/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/include/video_player_avfoundation/messages.g.h index d06c3fd0179..5806521efb3 100644 --- a/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/include/video_player_avfoundation/messages.g.h +++ b/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/include/video_player_avfoundation/messages.g.h @@ -16,6 +16,9 @@ NS_ASSUME_NONNULL_BEGIN @class FVPPlatformVideoViewCreationParams; @class FVPCreationOptions; @class FVPTexturePlayerIds; +@class FVPMediaSelectionVideoTrackData; +@class FVPAssetVideoTrackData; +@class FVPNativeVideoTrackData; /// Information passed to the platform view creation. @interface FVPPlatformVideoViewCreationParams : NSObject @@ -42,6 +45,60 @@ NS_ASSUME_NONNULL_BEGIN @property(nonatomic, assign) NSInteger textureId; @end +/// Video track data from AVAssetVariant (HLS variants) for iOS 15+. +@interface FVPMediaSelectionVideoTrackData : NSObject +/// `init` unavailable to enforce nonnull fields, see the `make` class method. +- (instancetype)init NS_UNAVAILABLE; ++ (instancetype)makeWithVariantIndex:(NSInteger)variantIndex + label:(nullable NSString *)label + bitrate:(nullable NSNumber *)bitrate + width:(nullable NSNumber *)width + height:(nullable NSNumber *)height + frameRate:(nullable NSNumber *)frameRate + codec:(nullable NSString *)codec + isSelected:(BOOL)isSelected; +@property(nonatomic, assign) NSInteger variantIndex; +@property(nonatomic, copy, nullable) NSString *label; +@property(nonatomic, strong, nullable) NSNumber *bitrate; +@property(nonatomic, strong, nullable) NSNumber *width; +@property(nonatomic, strong, nullable) NSNumber *height; +@property(nonatomic, strong, nullable) NSNumber *frameRate; +@property(nonatomic, copy, nullable) NSString *codec; +@property(nonatomic, assign) BOOL isSelected; +@end + +/// Video track data from AVAssetTrack (regular videos). +@interface FVPAssetVideoTrackData : NSObject +/// `init` unavailable to enforce nonnull fields, see the `make` class method. +- (instancetype)init NS_UNAVAILABLE; ++ (instancetype)makeWithTrackId:(NSInteger)trackId + label:(nullable NSString *)label + width:(nullable NSNumber *)width + height:(nullable NSNumber *)height + frameRate:(nullable NSNumber *)frameRate + codec:(nullable NSString *)codec + isSelected:(BOOL)isSelected; +@property(nonatomic, assign) NSInteger trackId; +@property(nonatomic, copy, nullable) NSString *label; +@property(nonatomic, strong, nullable) NSNumber *width; +@property(nonatomic, strong, nullable) NSNumber *height; +@property(nonatomic, strong, nullable) NSNumber *frameRate; +@property(nonatomic, copy, nullable) NSString *codec; +@property(nonatomic, assign) BOOL isSelected; +@end + +/// Container for video track data from iOS. +@interface FVPNativeVideoTrackData : NSObject ++ (instancetype)makeWithAssetTracks:(nullable NSArray *)assetTracks + mediaSelectionTracks: + (nullable NSArray *)mediaSelectionTracks; +/// Asset-based tracks (for regular videos) +@property(nonatomic, copy, nullable) NSArray *assetTracks; +/// Media selection tracks (for HLS variants on iOS 15+) +@property(nonatomic, copy, nullable) + NSArray *mediaSelectionTracks; +@end + /// The codec used by all APIs. NSObject *FVPGetMessagesCodec(void); @@ -78,6 +135,13 @@ extern void SetUpFVPAVFoundationVideoPlayerApiWithSuffix( - (void)seekTo:(NSInteger)position completion:(void (^)(FlutterError *_Nullable))completion; - (void)pauseWithError:(FlutterError *_Nullable *_Nonnull)error; - (void)disposeWithError:(FlutterError *_Nullable *_Nonnull)error; +/// Gets the available video tracks for the video. +- (void)getVideoTracks:(void (^)(FVPNativeVideoTrackData *_Nullable, + FlutterError *_Nullable))completion; +/// Selects a video track by setting preferredPeakBitRate. +/// Pass 0 to enable auto quality selection. +- (void)selectVideoTrackWithBitrate:(NSInteger)bitrate + error:(FlutterError *_Nullable *_Nonnull)error; @end extern void SetUpFVPVideoPlayerInstanceApi(id binaryMessenger, diff --git a/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/messages.g.m b/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/messages.g.m index 155ac2bacad..b4efb46c9e2 100644 --- a/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/messages.g.m +++ b/packages/video_player/video_player_avfoundation/darwin/video_player_avfoundation/Sources/video_player_avfoundation/messages.g.m @@ -48,6 +48,24 @@ + (nullable FVPTexturePlayerIds *)nullableFromList:(NSArray *)list; - (NSArray *)toList; @end +@interface FVPMediaSelectionVideoTrackData () ++ (FVPMediaSelectionVideoTrackData *)fromList:(NSArray *)list; ++ (nullable FVPMediaSelectionVideoTrackData *)nullableFromList:(NSArray *)list; +- (NSArray *)toList; +@end + +@interface FVPAssetVideoTrackData () ++ (FVPAssetVideoTrackData *)fromList:(NSArray *)list; ++ (nullable FVPAssetVideoTrackData *)nullableFromList:(NSArray *)list; +- (NSArray *)toList; +@end + +@interface FVPNativeVideoTrackData () ++ (FVPNativeVideoTrackData *)fromList:(NSArray *)list; ++ (nullable FVPNativeVideoTrackData *)nullableFromList:(NSArray *)list; +- (NSArray *)toList; +@end + @implementation FVPPlatformVideoViewCreationParams + (instancetype)makeWithPlayerId:(NSInteger)playerId { FVPPlatformVideoViewCreationParams *pigeonResult = @@ -120,6 +138,126 @@ + (nullable FVPTexturePlayerIds *)nullableFromList:(NSArray *)list { } @end +@implementation FVPMediaSelectionVideoTrackData ++ (instancetype)makeWithVariantIndex:(NSInteger)variantIndex + label:(nullable NSString *)label + bitrate:(nullable NSNumber *)bitrate + width:(nullable NSNumber *)width + height:(nullable NSNumber *)height + frameRate:(nullable NSNumber *)frameRate + codec:(nullable NSString *)codec + isSelected:(BOOL)isSelected { + FVPMediaSelectionVideoTrackData *pigeonResult = [[FVPMediaSelectionVideoTrackData alloc] init]; + pigeonResult.variantIndex = variantIndex; + pigeonResult.label = label; + pigeonResult.bitrate = bitrate; + pigeonResult.width = width; + pigeonResult.height = height; + pigeonResult.frameRate = frameRate; + pigeonResult.codec = codec; + pigeonResult.isSelected = isSelected; + return pigeonResult; +} ++ (FVPMediaSelectionVideoTrackData *)fromList:(NSArray *)list { + FVPMediaSelectionVideoTrackData *pigeonResult = [[FVPMediaSelectionVideoTrackData alloc] init]; + pigeonResult.variantIndex = [GetNullableObjectAtIndex(list, 0) integerValue]; + pigeonResult.label = GetNullableObjectAtIndex(list, 1); + pigeonResult.bitrate = GetNullableObjectAtIndex(list, 2); + pigeonResult.width = GetNullableObjectAtIndex(list, 3); + pigeonResult.height = GetNullableObjectAtIndex(list, 4); + pigeonResult.frameRate = GetNullableObjectAtIndex(list, 5); + pigeonResult.codec = GetNullableObjectAtIndex(list, 6); + pigeonResult.isSelected = [GetNullableObjectAtIndex(list, 7) boolValue]; + return pigeonResult; +} ++ (nullable FVPMediaSelectionVideoTrackData *)nullableFromList:(NSArray *)list { + return (list) ? [FVPMediaSelectionVideoTrackData fromList:list] : nil; +} +- (NSArray *)toList { + return @[ + @(self.variantIndex), + self.label ?: [NSNull null], + self.bitrate ?: [NSNull null], + self.width ?: [NSNull null], + self.height ?: [NSNull null], + self.frameRate ?: [NSNull null], + self.codec ?: [NSNull null], + @(self.isSelected), + ]; +} +@end + +@implementation FVPAssetVideoTrackData ++ (instancetype)makeWithTrackId:(NSInteger)trackId + label:(nullable NSString *)label + width:(nullable NSNumber *)width + height:(nullable NSNumber *)height + frameRate:(nullable NSNumber *)frameRate + codec:(nullable NSString *)codec + isSelected:(BOOL)isSelected { + FVPAssetVideoTrackData *pigeonResult = [[FVPAssetVideoTrackData alloc] init]; + pigeonResult.trackId = trackId; + pigeonResult.label = label; + pigeonResult.width = width; + pigeonResult.height = height; + pigeonResult.frameRate = frameRate; + pigeonResult.codec = codec; + pigeonResult.isSelected = isSelected; + return pigeonResult; +} ++ (FVPAssetVideoTrackData *)fromList:(NSArray *)list { + FVPAssetVideoTrackData *pigeonResult = [[FVPAssetVideoTrackData alloc] init]; + pigeonResult.trackId = [GetNullableObjectAtIndex(list, 0) integerValue]; + pigeonResult.label = GetNullableObjectAtIndex(list, 1); + pigeonResult.width = GetNullableObjectAtIndex(list, 2); + pigeonResult.height = GetNullableObjectAtIndex(list, 3); + pigeonResult.frameRate = GetNullableObjectAtIndex(list, 4); + pigeonResult.codec = GetNullableObjectAtIndex(list, 5); + pigeonResult.isSelected = [GetNullableObjectAtIndex(list, 6) boolValue]; + return pigeonResult; +} ++ (nullable FVPAssetVideoTrackData *)nullableFromList:(NSArray *)list { + return (list) ? [FVPAssetVideoTrackData fromList:list] : nil; +} +- (NSArray *)toList { + return @[ + @(self.trackId), + self.label ?: [NSNull null], + self.width ?: [NSNull null], + self.height ?: [NSNull null], + self.frameRate ?: [NSNull null], + self.codec ?: [NSNull null], + @(self.isSelected), + ]; +} +@end + +@implementation FVPNativeVideoTrackData ++ (instancetype)makeWithAssetTracks:(nullable NSArray *)assetTracks + mediaSelectionTracks: + (nullable NSArray *)mediaSelectionTracks { + FVPNativeVideoTrackData *pigeonResult = [[FVPNativeVideoTrackData alloc] init]; + pigeonResult.assetTracks = assetTracks; + pigeonResult.mediaSelectionTracks = mediaSelectionTracks; + return pigeonResult; +} ++ (FVPNativeVideoTrackData *)fromList:(NSArray *)list { + FVPNativeVideoTrackData *pigeonResult = [[FVPNativeVideoTrackData alloc] init]; + pigeonResult.assetTracks = GetNullableObjectAtIndex(list, 0); + pigeonResult.mediaSelectionTracks = GetNullableObjectAtIndex(list, 1); + return pigeonResult; +} ++ (nullable FVPNativeVideoTrackData *)nullableFromList:(NSArray *)list { + return (list) ? [FVPNativeVideoTrackData fromList:list] : nil; +} +- (NSArray *)toList { + return @[ + self.assetTracks ?: [NSNull null], + self.mediaSelectionTracks ?: [NSNull null], + ]; +} +@end + @interface FVPMessagesPigeonCodecReader : FlutterStandardReader @end @implementation FVPMessagesPigeonCodecReader @@ -131,6 +269,12 @@ - (nullable id)readValueOfType:(UInt8)type { return [FVPCreationOptions fromList:[self readValue]]; case 131: return [FVPTexturePlayerIds fromList:[self readValue]]; + case 132: + return [FVPMediaSelectionVideoTrackData fromList:[self readValue]]; + case 133: + return [FVPAssetVideoTrackData fromList:[self readValue]]; + case 134: + return [FVPNativeVideoTrackData fromList:[self readValue]]; default: return [super readValueOfType:type]; } @@ -150,6 +294,15 @@ - (void)writeValue:(id)value { } else if ([value isKindOfClass:[FVPTexturePlayerIds class]]) { [self writeByte:131]; [self writeValue:[value toList]]; + } else if ([value isKindOfClass:[FVPMediaSelectionVideoTrackData class]]) { + [self writeByte:132]; + [self writeValue:[value toList]]; + } else if ([value isKindOfClass:[FVPAssetVideoTrackData class]]) { + [self writeByte:133]; + [self writeValue:[value toList]]; + } else if ([value isKindOfClass:[FVPNativeVideoTrackData class]]) { + [self writeByte:134]; + [self writeValue:[value toList]]; } else { [super writeValue:value]; } @@ -502,4 +655,53 @@ void SetUpFVPVideoPlayerInstanceApiWithSuffix(id binaryM [channel setMessageHandler:nil]; } } + /// Gets the available video tracks for the video. + { + FlutterBasicMessageChannel *channel = [[FlutterBasicMessageChannel alloc] + initWithName:[NSString stringWithFormat:@"%@%@", + @"dev.flutter.pigeon.video_player_avfoundation." + @"VideoPlayerInstanceApi.getVideoTracks", + messageChannelSuffix] + binaryMessenger:binaryMessenger + codec:FVPGetMessagesCodec()]; + if (api) { + NSCAssert([api respondsToSelector:@selector(getVideoTracks:)], + @"FVPVideoPlayerInstanceApi api (%@) doesn't respond to @selector(getVideoTracks:)", + api); + [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { + [api getVideoTracks:^(FVPNativeVideoTrackData *_Nullable output, + FlutterError *_Nullable error) { + callback(wrapResult(output, error)); + }]; + }]; + } else { + [channel setMessageHandler:nil]; + } + } + /// Selects a video track by setting preferredPeakBitRate. + /// Pass 0 to enable auto quality selection. + { + FlutterBasicMessageChannel *channel = [[FlutterBasicMessageChannel alloc] + initWithName:[NSString stringWithFormat:@"%@%@", + @"dev.flutter.pigeon.video_player_avfoundation." + @"VideoPlayerInstanceApi.selectVideoTrack", + messageChannelSuffix] + binaryMessenger:binaryMessenger + codec:FVPGetMessagesCodec()]; + if (api) { + NSCAssert([api respondsToSelector:@selector(selectVideoTrackWithBitrate:error:)], + @"FVPVideoPlayerInstanceApi api (%@) doesn't respond to " + @"@selector(selectVideoTrackWithBitrate:error:)", + api); + [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { + NSArray *args = message; + NSInteger arg_bitrate = [GetNullableObjectAtIndex(args, 0) integerValue]; + FlutterError *error; + [api selectVideoTrackWithBitrate:arg_bitrate error:&error]; + callback(wrapResult(nil, error)); + }]; + } else { + [channel setMessageHandler:nil]; + } + } } diff --git a/packages/video_player/video_player_avfoundation/example/pubspec.yaml b/packages/video_player/video_player_avfoundation/example/pubspec.yaml index cc176e75c3f..53cfd8b7452 100644 --- a/packages/video_player/video_player_avfoundation/example/pubspec.yaml +++ b/packages/video_player/video_player_avfoundation/example/pubspec.yaml @@ -16,7 +16,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.3.0 + video_player_platform_interface: + path: ../../video_player_platform_interface dev_dependencies: flutter_test: diff --git a/packages/video_player/video_player_avfoundation/lib/src/avfoundation_video_player.dart b/packages/video_player/video_player_avfoundation/lib/src/avfoundation_video_player.dart index 834b36ed6b0..54ef598a51d 100644 --- a/packages/video_player/video_player_avfoundation/lib/src/avfoundation_video_player.dart +++ b/packages/video_player/video_player_avfoundation/lib/src/avfoundation_video_player.dart @@ -21,8 +21,7 @@ class AVFoundationVideoPlayer extends VideoPlayerPlatform { /// Creates a new AVFoundation-based video player implementation instance. AVFoundationVideoPlayer({ @visibleForTesting AVFoundationVideoPlayerApi? pluginApi, - @visibleForTesting - VideoPlayerInstanceApi Function(int playerId)? playerApiProvider, + @visibleForTesting VideoPlayerInstanceApi Function(int playerId)? playerApiProvider, }) : _api = pluginApi ?? AVFoundationVideoPlayerApi(), _playerApiProvider = playerApiProvider ?? _productionApiProvider; @@ -71,9 +70,7 @@ class AVFoundationVideoPlayer 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'); } uri = await _api.getAssetUrl(asset, dataSource.package); if (uri == null) { @@ -178,6 +175,86 @@ class AVFoundationVideoPlayer extends VideoPlayerPlatform { return _api.setMixWithOthers(mixWithOthers); } + @override + Future> getVideoTracks(int playerId) async { + final NativeVideoTrackData nativeData = await _playerWith( + id: playerId, + ).getVideoTracks(); + final tracks = []; + + // Convert HLS variant tracks (iOS 15+) + if (nativeData.mediaSelectionTracks != null) { + for (final MediaSelectionVideoTrackData track in nativeData.mediaSelectionTracks!) { + // Use bitrate as the track ID for HLS variants + final trackId = 'variant_${track.bitrate ?? track.variantIndex}'; + // 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, + ), + ); + } + } + + // Convert asset tracks (for regular videos) + if (nativeData.assetTracks != null) { + for (final AssetVideoTrackData track in nativeData.assetTracks!) { + final trackId = 'asset_${track.trackId}'; + // 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, + width: track.width, + height: track.height, + frameRate: track.frameRate, + codec: track.codec, + ), + ); + } + } + + return tracks; + } + + @override + Future selectVideoTrack(int playerId, VideoTrack? track) async { + if (track == null) { + // Auto quality - pass 0 to clear preferredPeakBitRate + await _playerWith(id: playerId).selectVideoTrack(0); + return; + } + + // Use bitrate directly from the track for HLS quality selection + if (track.bitrate != null) { + await _playerWith(id: playerId).selectVideoTrack(track.bitrate!); + return; + } + + // For asset tracks without bitrate, we can't really select them differently + // Just ignore the selection for non-HLS content + } + + @override + bool isVideoTrackSupportAvailable() { + // iOS with AVFoundation supports video track selection + return true; + } + @override Widget buildView(int playerId) { return buildViewWithOptions(VideoViewOptions(playerId: playerId)); @@ -189,9 +266,7 @@ class AVFoundationVideoPlayer 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() => _buildPlatformView(playerId), }; } @@ -218,11 +293,8 @@ class AVFoundationVideoPlayer extends VideoPlayerPlatform { /// An instance of a video player, corresponding to a single player ID in /// [AVFoundationVideoPlayer]. class _PlayerInstance { - _PlayerInstance( - this._api, - this.viewState, { - required EventChannel eventChannel, - }) : _eventChannel = eventChannel; + _PlayerInstance(this._api, this.viewState, {required EventChannel eventChannel}) + : _eventChannel = eventChannel; final VideoPlayerInstanceApi _api; final VideoPlayerViewState viewState; @@ -260,6 +332,14 @@ class _PlayerInstance { return _eventStreamController.stream; } + Future getVideoTracks() { + return _api.getVideoTracks(); + } + + Future selectVideoTrack(int bitrate) { + return _api.selectVideoTrack(bitrate); + } + Future dispose() async { await _eventSubscription?.cancel(); unawaited(_eventStreamController.close()); diff --git a/packages/video_player/video_player_avfoundation/lib/src/messages.g.dart b/packages/video_player/video_player_avfoundation/lib/src/messages.g.dart index 9072c153f95..84cb91c7b61 100644 --- a/packages/video_player/video_player_avfoundation/lib/src/messages.g.dart +++ b/packages/video_player/video_player_avfoundation/lib/src/messages.g.dart @@ -154,6 +154,200 @@ class TexturePlayerIds { int get hashCode => Object.hashAll(_toList()); } +/// Video track data from AVAssetVariant (HLS variants) for iOS 15+. +class MediaSelectionVideoTrackData { + MediaSelectionVideoTrackData({ + required this.variantIndex, + this.label, + this.bitrate, + this.width, + this.height, + this.frameRate, + this.codec, + required this.isSelected, + }); + + int variantIndex; + + String? label; + + int? bitrate; + + int? width; + + int? height; + + double? frameRate; + + String? codec; + + bool isSelected; + + List _toList() { + return [ + variantIndex, + label, + bitrate, + width, + height, + frameRate, + codec, + isSelected, + ]; + } + + Object encode() { + return _toList(); + } + + static MediaSelectionVideoTrackData decode(Object result) { + result as List; + return MediaSelectionVideoTrackData( + variantIndex: result[0]! as int, + label: result[1] as String?, + bitrate: result[2] as int?, + width: result[3] as int?, + height: result[4] as int?, + frameRate: result[5] as double?, + codec: result[6] as String?, + isSelected: result[7]! as bool, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! MediaSelectionVideoTrackData || + other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()); +} + +/// Video track data from AVAssetTrack (regular videos). +class AssetVideoTrackData { + AssetVideoTrackData({ + required this.trackId, + this.label, + this.width, + this.height, + this.frameRate, + this.codec, + required this.isSelected, + }); + + int trackId; + + String? label; + + int? width; + + int? height; + + double? frameRate; + + String? codec; + + bool isSelected; + + List _toList() { + return [ + trackId, + label, + width, + height, + frameRate, + codec, + isSelected, + ]; + } + + Object encode() { + return _toList(); + } + + static AssetVideoTrackData decode(Object result) { + result as List; + return AssetVideoTrackData( + trackId: result[0]! as int, + label: result[1] as String?, + width: result[2] as int?, + height: result[3] as int?, + frameRate: result[4] as double?, + codec: result[5] as String?, + isSelected: result[6]! as bool, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! AssetVideoTrackData || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()); +} + +/// Container for video track data from iOS. +class NativeVideoTrackData { + NativeVideoTrackData({this.assetTracks, this.mediaSelectionTracks}); + + /// Asset-based tracks (for regular videos) + List? assetTracks; + + /// Media selection tracks (for HLS variants on iOS 15+) + List? mediaSelectionTracks; + + List _toList() { + return [assetTracks, mediaSelectionTracks]; + } + + Object encode() { + return _toList(); + } + + static NativeVideoTrackData decode(Object result) { + result as List; + return NativeVideoTrackData( + assetTracks: (result[0] as List?)?.cast(), + mediaSelectionTracks: (result[1] as List?) + ?.cast(), + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! NativeVideoTrackData || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()); +} + class _PigeonCodec extends StandardMessageCodec { const _PigeonCodec(); @override @@ -170,6 +364,15 @@ class _PigeonCodec extends StandardMessageCodec { } else if (value is TexturePlayerIds) { buffer.putUint8(131); writeValue(buffer, value.encode()); + } else if (value is MediaSelectionVideoTrackData) { + buffer.putUint8(132); + writeValue(buffer, value.encode()); + } else if (value is AssetVideoTrackData) { + buffer.putUint8(133); + writeValue(buffer, value.encode()); + } else if (value is NativeVideoTrackData) { + buffer.putUint8(134); + writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); } @@ -184,6 +387,12 @@ class _PigeonCodec extends StandardMessageCodec { return CreationOptions.decode(readValue(buffer)!); case 131: return TexturePlayerIds.decode(readValue(buffer)!); + case 132: + return MediaSelectionVideoTrackData.decode(readValue(buffer)!); + case 133: + return AssetVideoTrackData.decode(readValue(buffer)!); + case 134: + return NativeVideoTrackData.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); } @@ -582,4 +791,64 @@ class VideoPlayerInstanceApi { return; } } + + /// Gets the available video tracks for the video. + Future getVideoTracks() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.video_player_avfoundation.VideoPlayerInstanceApi.getVideoTracks$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as NativeVideoTrackData?)!; + } + } + + /// Selects a video track by setting preferredPeakBitRate. + /// Pass 0 to enable auto quality selection. + Future selectVideoTrack(int bitrate) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.video_player_avfoundation.VideoPlayerInstanceApi.selectVideoTrack$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [bitrate], + ); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } } diff --git a/packages/video_player/video_player_avfoundation/pigeons/messages.dart b/packages/video_player/video_player_avfoundation/pigeons/messages.dart index 6e872dec145..fc9fe59f8a4 100644 --- a/packages/video_player/video_player_avfoundation/pigeons/messages.dart +++ b/packages/video_player/video_player_avfoundation/pigeons/messages.dart @@ -56,6 +56,61 @@ abstract class AVFoundationVideoPlayerApi { String? getAssetUrl(String asset, String? package); } +/// Video track data from AVAssetVariant (HLS variants) for iOS 15+. +class MediaSelectionVideoTrackData { + MediaSelectionVideoTrackData({ + required this.variantIndex, + this.label, + this.bitrate, + this.width, + this.height, + this.frameRate, + this.codec, + required this.isSelected, + }); + + int variantIndex; + String? label; + int? bitrate; + int? width; + int? height; + double? frameRate; + String? codec; + bool isSelected; +} + +/// Video track data from AVAssetTrack (regular videos). +class AssetVideoTrackData { + AssetVideoTrackData({ + required this.trackId, + this.label, + this.width, + this.height, + this.frameRate, + this.codec, + required this.isSelected, + }); + + int trackId; + String? label; + int? width; + int? height; + double? frameRate; + String? codec; + bool isSelected; +} + +/// Container for video track data from iOS. +class NativeVideoTrackData { + NativeVideoTrackData({this.assetTracks, this.mediaSelectionTracks}); + + /// Asset-based tracks (for regular videos) + List? assetTracks; + + /// Media selection tracks (for HLS variants on iOS 15+) + List? mediaSelectionTracks; +} + @HostApi() abstract class VideoPlayerInstanceApi { @ObjCSelector('setLooping:') @@ -72,4 +127,14 @@ abstract class VideoPlayerInstanceApi { void seekTo(int position); void pause(); void dispose(); + + /// Gets the available video tracks for the video. + @async + @ObjCSelector('getVideoTracks') + NativeVideoTrackData getVideoTracks(); + + /// Selects a video track by setting preferredPeakBitRate. + /// Pass 0 to enable auto quality selection. + @ObjCSelector('selectVideoTrackWithBitrate:') + void selectVideoTrack(int bitrate); } diff --git a/packages/video_player/video_player_avfoundation/pubspec.yaml b/packages/video_player/video_player_avfoundation/pubspec.yaml index 9c326136d92..9f26f8e5b31 100644 --- a/packages/video_player/video_player_avfoundation/pubspec.yaml +++ b/packages/video_player/video_player_avfoundation/pubspec.yaml @@ -24,7 +24,8 @@ flutter: dependencies: flutter: sdk: flutter - video_player_platform_interface: ^6.3.0 + video_player_platform_interface: + path: ../video_player_platform_interface dev_dependencies: build_runner: ^2.3.3 diff --git a/packages/video_player/video_player_avfoundation/test/avfoundation_video_player_test.dart b/packages/video_player/video_player_avfoundation/test/avfoundation_video_player_test.dart index c16d5bcb08e..e9109cd0df8 100644 --- a/packages/video_player/video_player_avfoundation/test/avfoundation_video_player_test.dart +++ b/packages/video_player/video_player_avfoundation/test/avfoundation_video_player_test.dart @@ -690,5 +690,179 @@ void main() { ]), ); }); + + group('video tracks', () { + test('isVideoTrackSupportAvailable returns true', () { + final (AVFoundationVideoPlayer player, _, _) = setUpMockPlayer( + playerId: 1, + textureId: 101, + ); + + expect(player.isVideoTrackSupportAvailable(), true); + }); + + test('getVideoTracks returns empty list when no tracks', () async { + final ( + AVFoundationVideoPlayer player, + _, + MockVideoPlayerInstanceApi api, + ) = setUpMockPlayer( + playerId: 1, + textureId: 101, + ); + when( + api.getVideoTracks(), + ).thenAnswer((_) async => NativeVideoTrackData()); + + final List tracks = await player.getVideoTracks(1); + + expect(tracks, isEmpty); + }); + + test( + 'getVideoTracks converts HLS variant tracks to VideoTrack', + () async { + final ( + AVFoundationVideoPlayer player, + _, + MockVideoPlayerInstanceApi api, + ) = setUpMockPlayer( + playerId: 1, + textureId: 101, + ); + when(api.getVideoTracks()).thenAnswer( + (_) async => NativeVideoTrackData( + mediaSelectionTracks: [ + MediaSelectionVideoTrackData( + variantIndex: 0, + label: '1080p', + isSelected: true, + bitrate: 5000000, + width: 1920, + height: 1080, + frameRate: 30.0, + codec: 'avc1', + ), + MediaSelectionVideoTrackData( + variantIndex: 1, + label: '720p', + isSelected: false, + bitrate: 2500000, + width: 1280, + height: 720, + frameRate: 30.0, + codec: 'avc1', + ), + ], + ), + ); + + final List tracks = await player.getVideoTracks(1); + + expect(tracks.length, 2); + + expect(tracks[0].id, 'variant_5000000'); + expect(tracks[0].label, '1080p'); + expect(tracks[0].isSelected, true); + expect(tracks[0].bitrate, 5000000); + expect(tracks[0].width, 1920); + expect(tracks[0].height, 1080); + expect(tracks[0].frameRate, 30.0); + expect(tracks[0].codec, 'avc1'); + + expect(tracks[1].id, 'variant_2500000'); + expect(tracks[1].label, '720p'); + expect(tracks[1].isSelected, false); + }, + ); + + test( + 'getVideoTracks generates label from resolution if not provided', + () async { + final ( + AVFoundationVideoPlayer player, + _, + MockVideoPlayerInstanceApi api, + ) = setUpMockPlayer( + playerId: 1, + textureId: 101, + ); + when(api.getVideoTracks()).thenAnswer( + (_) async => NativeVideoTrackData( + mediaSelectionTracks: [ + MediaSelectionVideoTrackData( + variantIndex: 0, + isSelected: true, + bitrate: 5000000, + width: 1920, + height: 1080, + ), + ], + ), + ); + + final List tracks = await player.getVideoTracks(1); + + expect(tracks.length, 1); + expect(tracks[0].label, '1080p'); + }, + ); + + test( + 'selectVideoTrack with null sets auto quality (bitrate 0)', + () async { + final ( + AVFoundationVideoPlayer player, + _, + MockVideoPlayerInstanceApi api, + ) = setUpMockPlayer( + playerId: 1, + textureId: 101, + ); + when(api.selectVideoTrack(0)).thenAnswer((_) async {}); + + await player.selectVideoTrack(1, null); + + verify(api.selectVideoTrack(0)); + }, + ); + + test('selectVideoTrack with track uses bitrate', () async { + final ( + AVFoundationVideoPlayer player, + _, + MockVideoPlayerInstanceApi api, + ) = setUpMockPlayer( + playerId: 1, + textureId: 101, + ); + when(api.selectVideoTrack(5000000)).thenAnswer((_) async {}); + + const track = VideoTrack( + id: 'variant_5000000', + isSelected: false, + bitrate: 5000000, + ); + await player.selectVideoTrack(1, track); + + verify(api.selectVideoTrack(5000000)); + }); + + test('selectVideoTrack ignores track without bitrate', () async { + final ( + AVFoundationVideoPlayer player, + _, + MockVideoPlayerInstanceApi api, + ) = setUpMockPlayer( + playerId: 1, + textureId: 101, + ); + + const track = VideoTrack(id: 'asset_123', isSelected: false); + await player.selectVideoTrack(1, track); + + verifyNever(api.selectVideoTrack(any)); + }); + }); }); } diff --git a/packages/video_player/video_player_avfoundation/test/avfoundation_video_player_test.mocks.dart b/packages/video_player/video_player_avfoundation/test/avfoundation_video_player_test.mocks.dart index 8caf6ad8dc4..6afa3dd8dcb 100644 --- a/packages/video_player/video_player_avfoundation/test/avfoundation_video_player_test.mocks.dart +++ b/packages/video_player/video_player_avfoundation/test/avfoundation_video_player_test.mocks.dart @@ -29,6 +29,12 @@ class _FakeTexturePlayerIds_0 extends _i1.SmartFake : super(parent, parentInvocation); } +class _FakeNativeVideoTrackData_1 extends _i1.SmartFake + implements _i2.NativeVideoTrackData { + _FakeNativeVideoTrackData_1(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + /// A class which mocks [AVFoundationVideoPlayerApi]. /// /// See the documentation for Mockito's code generation for more information. @@ -198,4 +204,33 @@ class MockVideoPlayerInstanceApi extends _i1.Mock returnValueForMissingStub: _i4.Future.value(), ) as _i4.Future); + + @override + _i4.Future<_i2.NativeVideoTrackData> getVideoTracks() => + (super.noSuchMethod( + Invocation.method(#getVideoTracks, []), + returnValue: _i4.Future<_i2.NativeVideoTrackData>.value( + _FakeNativeVideoTrackData_1( + this, + Invocation.method(#getVideoTracks, []), + ), + ), + returnValueForMissingStub: + _i4.Future<_i2.NativeVideoTrackData>.value( + _FakeNativeVideoTrackData_1( + this, + Invocation.method(#getVideoTracks, []), + ), + ), + ) + as _i4.Future<_i2.NativeVideoTrackData>); + + @override + _i4.Future selectVideoTrack(int? bitrate) => + (super.noSuchMethod( + Invocation.method(#selectVideoTrack, [bitrate]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); } diff --git a/packages/video_player/video_player_platform_interface/CHANGELOG.md b/packages/video_player/video_player_platform_interface/CHANGELOG.md index a0e403be5dc..a073d786e79 100644 --- a/packages/video_player/video_player_platform_interface/CHANGELOG.md +++ b/packages/video_player/video_player_platform_interface/CHANGELOG.md @@ -1,6 +1,7 @@ ## NEXT * Updates minimum supported SDK version to Flutter 3.32/Dart 3.8. +* Adds `VideoTrack` class and `getVideoTracks()`, `selectVideoTrack()`, `isVideoTrackSupportAvailable()` methods for video track (quality) selection. ## 6.6.0 diff --git a/packages/video_player/video_player_platform_interface/lib/video_player_platform_interface.dart b/packages/video_player/video_player_platform_interface/lib/video_player_platform_interface.dart index 1cec5f42c21..ca75be5bb8a 100644 --- a/packages/video_player/video_player_platform_interface/lib/video_player_platform_interface.dart +++ b/packages/video_player/video_player_platform_interface/lib/video_player_platform_interface.dart @@ -153,6 +153,46 @@ abstract class VideoPlayerPlatform extends PlatformInterface { bool isAudioTrackSupportAvailable() { return false; } + + /// 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+. + Future> getVideoTracks(int playerId) { + throw UnimplementedError('getVideoTracks() has not been implemented.'); + } + + /// 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. + Future selectVideoTrack(int playerId, VideoTrack? track) { + throw UnimplementedError('selectVideoTrack() has not been implemented.'); + } + + /// Returns whether video track selection is supported on this platform. + /// + /// This method allows developers to query at runtime whether the current + /// platform supports video track (quality) selection functionality. This is + /// useful for platforms like web where video track selection may not be + /// available. + /// + /// Returns `true` if [getVideoTracks] and [selectVideoTrack] are supported, + /// `false` otherwise. + /// + /// The default implementation returns `false`. Platform implementations + /// should override this to return `true` if they support video track selection. + bool isVideoTrackSupportAvailable() { + return false; + } } class _PlaceholderImplementation extends VideoPlayerPlatform {} @@ -652,3 +692,102 @@ class VideoAudioTrack { 'channelCount: $channelCount, ' 'codec: $codec)'; } + +/// 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, + }); + + /// 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"). + /// + /// May be null 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_platform_interface/test/video_player_platform_interface_test.dart b/packages/video_player/video_player_platform_interface/test/video_player_platform_interface_test.dart index 2d920161ec9..74349b1d491 100644 --- a/packages/video_player/video_player_platform_interface/test/video_player_platform_interface_test.dart +++ b/packages/video_player/video_player_platform_interface/test/video_player_platform_interface_test.dart @@ -40,4 +40,121 @@ void main() { test('default implementation isAudioTrackSupportAvailable returns false', () { expect(initialInstance.isAudioTrackSupportAvailable(), false); }); + + test('default implementation getVideoTracks throws unimplemented', () async { + await expectLater( + () => initialInstance.getVideoTracks(1), + throwsUnimplementedError, + ); + }); + + test( + 'default implementation selectVideoTrack throws unimplemented', + () async { + await expectLater( + () => initialInstance.selectVideoTrack( + 1, + const VideoTrack(id: 'test', isSelected: false), + ), + throwsUnimplementedError, + ); + }, + ); + + test('default implementation isVideoTrackSupportAvailable returns false', () { + expect(initialInstance.isVideoTrackSupportAvailable(), false); + }); + + group('VideoTrack', () { + test('constructor creates instance with required fields', () { + const track = VideoTrack(id: 'track_1', isSelected: true); + expect(track.id, 'track_1'); + expect(track.isSelected, true); + expect(track.label, isNull); + expect(track.bitrate, isNull); + expect(track.width, isNull); + expect(track.height, isNull); + expect(track.frameRate, isNull); + expect(track.codec, isNull); + }); + + test('constructor creates instance with all fields', () { + const track = VideoTrack( + id: 'track_1', + isSelected: true, + label: '1080p', + bitrate: 5000000, + width: 1920, + height: 1080, + frameRate: 30.0, + codec: 'avc1', + ); + expect(track.id, 'track_1'); + expect(track.isSelected, true); + expect(track.label, '1080p'); + expect(track.bitrate, 5000000); + expect(track.width, 1920); + expect(track.height, 1080); + expect(track.frameRate, 30.0); + expect(track.codec, 'avc1'); + }); + + test('equality works correctly', () { + const track1 = VideoTrack( + id: 'track_1', + isSelected: true, + label: '1080p', + bitrate: 5000000, + ); + const track2 = VideoTrack( + id: 'track_1', + isSelected: true, + label: '1080p', + bitrate: 5000000, + ); + const track3 = VideoTrack(id: 'track_2', isSelected: false); + + expect(track1, equals(track2)); + expect(track1, isNot(equals(track3))); + }); + + test('hashCode is consistent with equality', () { + const track1 = VideoTrack( + id: 'track_1', + isSelected: true, + label: '1080p', + ); + const track2 = VideoTrack( + id: 'track_1', + isSelected: true, + label: '1080p', + ); + + expect(track1.hashCode, equals(track2.hashCode)); + }); + + test('toString returns expected format', () { + const track = VideoTrack( + id: 'track_1', + isSelected: true, + label: '1080p', + bitrate: 5000000, + width: 1920, + height: 1080, + frameRate: 30.0, + codec: 'avc1', + ); + + final str = track.toString(); + expect(str, contains('VideoTrack')); + expect(str, contains('id: track_1')); + expect(str, contains('isSelected: true')); + expect(str, contains('label: 1080p')); + expect(str, contains('bitrate: 5000000')); + expect(str, contains('width: 1920')); + expect(str, contains('height: 1080')); + expect(str, contains('frameRate: 30.0')); + expect(str, contains('codec: avc1')); + }); + }); } diff --git a/packages/video_player/video_player_web/CHANGELOG.md b/packages/video_player/video_player_web/CHANGELOG.md index 2f383e86f2d..47eb400127a 100644 --- a/packages/video_player/video_player_web/CHANGELOG.md +++ b/packages/video_player/video_player_web/CHANGELOG.md @@ -1,6 +1,7 @@ ## NEXT * Updates minimum supported SDK version to Flutter 3.32/Dart 3.8. +* Adds stub implementation for video track selection (not supported on web). ## 2.4.0 diff --git a/packages/video_player/video_player_web/example/pubspec.yaml b/packages/video_player/video_player_web/example/pubspec.yaml index 92f965e988d..8b823ecf53a 100644 --- a/packages/video_player/video_player_web/example/pubspec.yaml +++ b/packages/video_player/video_player_web/example/pubspec.yaml @@ -8,7 +8,8 @@ environment: dependencies: flutter: sdk: flutter - video_player_platform_interface: ^6.3.0 + video_player_platform_interface: + path: ../../video_player_platform_interface video_player_web: path: ../ web: ^1.0.0 diff --git a/packages/video_player/video_player_web/lib/video_player_web.dart b/packages/video_player/video_player_web/lib/video_player_web.dart index ecc8e427d2d..3423666082c 100644 --- a/packages/video_player/video_player_web/lib/video_player_web.dart +++ b/packages/video_player/video_player_web/lib/video_player_web.dart @@ -169,4 +169,20 @@ class VideoPlayerPlugin extends VideoPlayerPlatform { /// Sets the audio mode to mix with other sources (ignored). @override Future setMixWithOthers(bool mixWithOthers) => Future.value(); + + @override + Future> getVideoTracks(int playerId) { + throw UnimplementedError('getVideoTracks() is not supported on web'); + } + + @override + Future selectVideoTrack(int playerId, VideoTrack? track) { + throw UnimplementedError('selectVideoTrack() is not supported on web'); + } + + @override + bool isVideoTrackSupportAvailable() { + // Web does not support video track selection + return false; + } } diff --git a/packages/video_player/video_player_web/pubspec.yaml b/packages/video_player/video_player_web/pubspec.yaml index 39148c8fd3c..a974f97a8bb 100644 --- a/packages/video_player/video_player_web/pubspec.yaml +++ b/packages/video_player/video_player_web/pubspec.yaml @@ -21,7 +21,8 @@ dependencies: sdk: flutter flutter_web_plugins: sdk: flutter - video_player_platform_interface: ^6.4.0 + video_player_platform_interface: + path: ../video_player_platform_interface web: ">=0.5.1 <2.0.0" dev_dependencies: