diff --git a/packages/video_player/video_player/CHANGELOG.md b/packages/video_player/video_player/CHANGELOG.md index afb90e892d10..c9460d9ca2e7 100644 --- a/packages/video_player/video_player/CHANGELOG.md +++ b/packages/video_player/video_player/CHANGELOG.md @@ -1,3 +1,9 @@ +## 2.14.0 + +* Adds video quality selection support for HLS/DASH adaptive streams via + `getVideoTracks()`, `selectVideoTrack()`, and + `isVideoTrackSupportAvailable()` methods. + ## 2.13.0 * Adds `preventsDisplaySleepDuringVideoPlayback` to `VideoPlayerOptions` and diff --git a/packages/video_player/video_player/example/lib/main.dart b/packages/video_player/video_player/example/lib/main.dart index 61e24a891079..70dfb4137aa4 100644 --- a/packages/video_player/video_player/example/lib/main.dart +++ b/packages/video_player/video_player/example/lib/main.dart @@ -12,6 +12,7 @@ import 'package:flutter/material.dart'; import 'package:video_player/video_player.dart'; import 'audio_tracks_demo.dart'; +import 'video_tracks_demo.dart'; void main() { runApp(MaterialApp(home: _App())); @@ -27,6 +28,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 000000000000..d26eef08f255 --- /dev/null +++ b/packages/video_player/video_player/example/lib/video_tracks_demo.dart @@ -0,0 +1,445 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:video_player/video_player.dart'; + +/// A demo page that showcases video track (quality) selection functionality. +class VideoTracksDemo extends StatefulWidget { + /// Creates a VideoTracksDemo widget. + const VideoTracksDemo({super.key}); + + @override + State createState() => _VideoTracksDemoState(); +} + +class _VideoTracksDemoState extends State { + VideoPlayerController? _controller; + List _videoTracks = []; + bool _isLoading = false; + String? _error; + bool _isAutoQuality = true; + + // Track previous state to detect relevant changes + bool _wasPlaying = false; + bool _wasInitialized = false; + + // Sample video URLs with multiple video tracks (HLS streams) + static const List _sampleVideos = [ + 'https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_16x9/bipbop_16x9_variant.m3u8', + 'https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_fmp4/master.m3u8', + 'https://flutter.github.io/assets-for-api-docs/assets/videos/butterfly.mp4', + ]; + + int _selectedVideoIndex = 0; + + @override + void initState() { + super.initState(); + _initializeVideo(); + } + + Future _initializeVideo() async { + setState(() { + _isLoading = true; + _error = null; + _isAutoQuality = true; + }); + + try { + await _controller?.dispose(); + + final controller = VideoPlayerController.networkUrl( + Uri.parse(_sampleVideos[_selectedVideoIndex]), + ); + _controller = controller; + + await controller.initialize(); + + // Add listener for video player state changes + _controller!.addListener(_onVideoPlayerValueChanged); + + // Initialize tracking variables + _wasPlaying = _controller!.value.isPlaying; + _wasInitialized = _controller!.value.isInitialized; + + // Get video tracks after initialization + await _loadVideoTracks(); + if (!mounted) { + return; + } + setState(() { + _isLoading = false; + }); + } catch (e) { + if (!mounted) { + return; + } + setState(() { + _error = 'Failed to initialize video: $e'; + _isLoading = false; + }); + } + } + + Future _loadVideoTracks() async { + final VideoPlayerController? controller = _controller; + if (controller == null || !controller.value.isInitialized) { + return; + } + + // Check if video track selection is supported + if (!controller.isVideoTrackSupportAvailable()) { + if (!mounted) { + return; + } + setState(() { + _error = 'Video track selection is not supported on this platform.'; + _videoTracks = []; + }); + return; + } + + try { + final List tracks = await controller.getVideoTracks(); + if (!mounted) { + return; + } + setState(() { + _videoTracks = tracks; + }); + } catch (e) { + if (!mounted) { + return; + } + setState(() { + _error = 'Failed to load video tracks: $e'; + }); + } + } + + Future _selectVideoTrack(VideoTrack? track) async { + final VideoPlayerController? controller = _controller; + if (controller == null) { + return; + } + + final ScaffoldMessengerState scaffoldMessenger = ScaffoldMessenger.of(context); + + try { + await controller.selectVideoTrack(track); + + setState(() { + _isAutoQuality = track == null; + }); + + // Reload tracks to update selection status + await _loadVideoTracks(); + + if (!mounted) { + return; + } + final message = track == null + ? 'Switched to automatic quality' + : 'Selected video track: ${_getTrackLabel(track)}'; + scaffoldMessenger.showSnackBar(SnackBar(content: Text(message))); + } catch (e) { + if (!mounted) { + return; + } + scaffoldMessenger.showSnackBar(SnackBar(content: Text('Failed to select video track: $e'))); + } + } + + String _getTrackLabel(VideoTrack track) { + if (track.label?.isNotEmpty ?? false) { + 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/lib/video_player.dart b/packages/video_player/video_player/lib/video_player.dart index 686529910e57..401ca0cce5ef 100644 --- a/packages/video_player/video_player/lib/video_player.dart +++ b/packages/video_player/video_player/lib/video_player.dart @@ -1058,6 +1058,72 @@ class VideoPlayerController extends ValueNotifier { } bool get _isDisposedOrNotInitialized => _isDisposed || !value.isInitialized; + + /// Gets the available video tracks for the video. + /// + /// The returned list contains a [VideoTrack] for each track available + /// for selection. + /// + /// For adaptive streams such as HLS or DASH, these often correspond to + /// different quality levels with different resolutions or bitrates. + /// For non-adaptive videos (MP4, MOV, etc.), platform implementations may + /// return one or more tracks, or an empty list, depending on the asset and + /// the metadata available. + /// + /// 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 is chosen for playback. + /// + /// Pass a [VideoTrack] to select a specific track. + /// Pass `null` to clear any manual selection and allow automatic selection. + /// + /// 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, + bitrate: track.bitrate, + width: track.width, + height: track.height, + frameRate: track.frameRate, + codec: track.codec, + ) + : null; + await _videoPlayerPlatform.selectVideoTrack(_playerId, platformTrack); + } + + /// Whether video track selection is supported on this platform. + /// + /// 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 { @@ -1450,3 +1516,108 @@ class ClosedCaption extends StatelessWidget { ); } } + +/// Represents a video track in a video with its metadata. +/// +/// For HLS/DASH adaptive streams, each [VideoTrack] represents a different +/// quality level (e.g., 1080p, 720p, 480p). For non-adaptive videos, platform +/// implementations may return a single track or no tracks, depending on the +/// metadata that is 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 adaptive streams + 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/pubspec.yaml b/packages/video_player/video_player/pubspec.yaml index ec2683a8b97d..d31c99258d90 100644 --- a/packages/video_player/video_player/pubspec.yaml +++ b/packages/video_player/video_player/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for displaying inline video with other Flutter widgets on Android, iOS, macOS and web. repository: https://github.com/flutter/packages/tree/main/packages/video_player/video_player issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+video_player%22 -version: 2.13.0 +version: 2.14.0 environment: sdk: ^3.12.0 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 fa66eb9ca090..a99b8734ea88 100644 --- a/packages/video_player/video_player/test/video_player_test.dart +++ b/packages/video_player/video_player/test/video_player_test.dart @@ -11,10 +11,10 @@ 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' - hide VideoAudioTrack; + hide VideoAudioTrack, VideoTrack; import 'package:video_player_platform_interface/video_player_platform_interface.dart' as platform_interface - show VideoAudioTrack; + show VideoAudioTrack, VideoTrack; const String _localhost = 'https://127.0.0.1'; final Uri _localhostUri = Uri.parse(_localhost); @@ -89,6 +89,15 @@ class FakeController extends ValueNotifier implements VideoPla @override Future setClosedCaptionFile(Future? closedCaptionFile) async {} + @override + Future> getVideoTracks() async => []; + + @override + Future selectVideoTrack(VideoTrack? track) async {} + + @override + bool isVideoTrackSupportAvailable() => false; + @override Future> getAudioTracks() async { return [ @@ -1912,6 +1921,102 @@ 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('getVideoTracks preserves null labels from platform', () async { + final controller = VideoPlayerController.networkUrl(_localhostUri); + await controller.initialize(); + + fakeVideoPlayerPlatform.setVideoTracksForPlayer( + controller.playerId, + [ + const platform_interface.VideoTrack(id: '0_0', isSelected: true, bitrate: 5000000), + ], + ); + + final List tracks = await controller.getVideoTracks(); + + expect(tracks.single.label, isNull); + }); + + 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 { @@ -2048,6 +2153,29 @@ class FakeVideoPlayerPlatform extends VideoPlayerPlatform { 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; + } + @override Future> getAudioTracks(int playerId) async { calls.add('getAudioTracks');