diff --git a/packages/video_player/video_player/CHANGELOG.md b/packages/video_player/video_player/CHANGELOG.md index 466a0a6f37b..a929e11637c 100644 --- a/packages/video_player/video_player/CHANGELOG.md +++ b/packages/video_player/video_player/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.11.0 + +* Adds audio track metadata support including bitrate, sample rate, channel count, and codec information. + ## 2.10.0 * Adds support for platform views as an optional way of displaying a video on Android and iOS. 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..0f51e6d3987 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 */, + A3677D96C5C9245FC9DDA03F /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -236,6 +237,23 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; + A3677D96C5C9245FC9DDA03F /* [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; + }; A526C4C26D549003F5EB64A6 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; diff --git a/packages/video_player/video_player/example/lib/audio_tracks_demo.dart b/packages/video_player/video_player/example/lib/audio_tracks_demo.dart new file mode 100644 index 00000000000..744881d2c60 --- /dev/null +++ b/packages/video_player/video_player/example/lib/audio_tracks_demo.dart @@ -0,0 +1,218 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:video_player/video_player.dart'; + +/// Demo page showing how to retrieve and display available audio tracks +class AudioTracksDemo extends StatefulWidget { + /// Creates an AudioTracksDemo widget. + const AudioTracksDemo({super.key}); + + @override + State createState() => _AudioTracksDemoState(); +} + +class _AudioTracksDemoState extends State { + VideoPlayerController? _controller; + List _audioTracks = []; + bool _isLoading = false; + + @override + void initState() { + super.initState(); + _initializeVideoPlayer(); + } + + Future _initializeVideoPlayer() async { + // Apple's test HLS stream with multiple audio tracks + const String videoUrl = + 'https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_16x9/bipbop_16x9_variant.m3u8'; + + _controller = VideoPlayerController.networkUrl(Uri.parse(videoUrl)); + + try { + await _controller!.initialize(); + setState(() { + // Video initialized + }); + + // Get audio tracks after initialization + await _getAudioTracks(); + } catch (e) { + debugPrint('Error initializing video player: $e'); + } + } + + Future _getAudioTracks() async { + if (_controller == null) { + return; + } + + setState(() { + _isLoading = true; + }); + + try { + final List tracks = await _controller!.getAudioTracks(); + setState(() { + _audioTracks = tracks; + _isLoading = false; + }); + } catch (e) { + debugPrint('Error getting audio tracks: $e'); + setState(() { + _isLoading = false; + }); + } + } + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Audio Tracks Demo'), + backgroundColor: Colors.blue, + ), + body: Column( + children: [ + // Video Player + if (_controller != null && _controller!.value.isInitialized) + AspectRatio( + aspectRatio: _controller!.value.aspectRatio, + child: VideoPlayer(_controller!), + ) + else + const SizedBox( + height: 200, + child: Center( + child: CircularProgressIndicator(), + ), + ), + + // Video Controls + if (_controller != null && _controller!.value.isInitialized) + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + onPressed: () { + setState(() { + if (_controller!.value.isPlaying) { + _controller!.pause(); + } else { + _controller!.play(); + } + }); + }, + icon: Icon( + _controller!.value.isPlaying + ? Icons.pause + : Icons.play_arrow, + ), + ), + IconButton( + onPressed: _getAudioTracks, + icon: const Icon(Icons.refresh), + tooltip: 'Refresh Audio Tracks', + ), + ], + ), + + const Divider(), + + // Audio Tracks Section + Expanded( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text( + 'Available Audio Tracks:', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + if (_isLoading) + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ], + ), + const SizedBox(height: 16), + if (_audioTracks.isEmpty && !_isLoading) + const Text( + 'No audio tracks found or video not initialized.', + style: TextStyle(color: Colors.grey), + ) + else + Expanded( + child: ListView.builder( + itemCount: _audioTracks.length, + itemBuilder: (BuildContext context, int index) { + final VideoAudioTrack track = _audioTracks[index]; + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + leading: CircleAvatar( + backgroundColor: track.isSelected + ? Colors.green + : Colors.grey, + child: Icon( + track.isSelected + ? Icons.check + : Icons.audiotrack, + color: Colors.white, + ), + ), + title: Text( + track.label, + style: TextStyle( + fontWeight: track.isSelected + ? FontWeight.bold + : FontWeight.normal, + ), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('ID: ${track.id}'), + Text('Language: ${track.language}'), + ], + ), + trailing: track.isSelected + ? const Chip( + label: Text('Selected'), + backgroundColor: Colors.green, + labelStyle: + TextStyle(color: Colors.white), + ) + : null, + ), + ); + }, + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/packages/video_player/video_player/example/lib/main.dart b/packages/video_player/video_player/example/lib/main.dart index 3f76f8c32e0..8a97daf2b30 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 'audio_tracks_demo.dart'; + void main() { runApp( MaterialApp( @@ -23,7 +25,7 @@ class _App extends StatelessWidget { @override Widget build(BuildContext context) { return DefaultTabController( - length: 3, + length: 4, child: Scaffold( key: const ValueKey('home_page'), appBar: AppBar( @@ -51,23 +53,22 @@ class _App extends StatelessWidget { ), Tab(icon: Icon(Icons.insert_drive_file), text: 'Asset'), Tab(icon: Icon(Icons.list), text: 'List example'), + Tab(icon: Icon(Icons.audiotrack), text: 'Audio Tracks'), ], ), ), body: TabBarView( children: [ _ViewTypeTabBar( - builder: (VideoViewType viewType) => - _BumbleBeeRemoteVideo(viewType), + builder: (VideoViewType viewType) => _BumbleBeeRemoteVideo(viewType), ), _ViewTypeTabBar( - builder: (VideoViewType viewType) => - _ButterFlyAssetVideo(viewType), + builder: (VideoViewType viewType) => _ButterFlyAssetVideo(viewType), ), _ViewTypeTabBar( - builder: (VideoViewType viewType) => - _ButterFlyAssetVideoInList(viewType), + builder: (VideoViewType viewType) => _ButterFlyAssetVideoInList(viewType), ), + const AudioTracksDemo(), ], ), ), @@ -159,8 +160,8 @@ class _ButterFlyAssetVideoInList extends StatelessWidget { title: Text('Video video'), ), Stack( - alignment: FractionalOffset.bottomRight + - const FractionalOffset(-0.1, -0.1), + alignment: + FractionalOffset.bottomRight + const FractionalOffset(-0.1, -0.1), children: [ _ButterFlyAssetVideo(viewType), Image.asset('assets/flutter-mark-square-64.png'), @@ -295,20 +296,50 @@ class _BumbleBeeRemoteVideo extends StatefulWidget { class _BumbleBeeRemoteVideoState extends State<_BumbleBeeRemoteVideo> { late VideoPlayerController _controller; + List _audioTracks = []; + bool _isLoadingTracks = false; Future _loadCaptions() async { - final String fileContents = await DefaultAssetBundle.of(context) - .loadString('assets/bumble_bee_captions.vtt'); - return WebVTTCaptionFile( - fileContents); // For vtt files, use WebVTTCaptionFile + final String fileContents = + await DefaultAssetBundle.of(context).loadString('assets/bumble_bee_captions.vtt'); + return WebVTTCaptionFile(fileContents); // For vtt files, use WebVTTCaptionFile + } + + String _formatQualityInfo(VideoAudioTrack track) { + final List parts = []; + + if (track.bitrate != null) { + final int kbps = (track.bitrate! / 1000).round(); + parts.add('${kbps}kbps'); + } + + if (track.channelCount != null) { + switch (track.channelCount!) { + case 1: + parts.add('Mono'); + case 2: + parts.add('Stereo'); + case 6: + parts.add('5.1'); + case 8: + parts.add('7.1'); + default: + parts.add('${track.channelCount}ch'); + } + } + + if (track.codec != null) { + parts.add(track.codec!.toUpperCase()); + } + + return parts.isEmpty ? 'Unknown Quality' : parts.join(' • '); } @override void initState() { super.initState(); _controller = VideoPlayerController.networkUrl( - Uri.parse( - 'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4'), + Uri.parse('https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4'), closedCaptionFile: _loadCaptions(), videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true), viewType: widget.viewType, @@ -349,6 +380,93 @@ class _BumbleBeeRemoteVideoState extends State<_BumbleBeeRemoteVideo> { ), ), ), + // Audio Tracks Button and Display + Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + ElevatedButton.icon( + onPressed: () async { + if (_controller.value.isInitialized) { + final List audioTracks = await _controller.getAudioTracks(); + setState(() { + _audioTracks = audioTracks; + _isLoadingTracks = false; + }); + } + }, + icon: _isLoadingTracks + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.audiotrack), + label: const Text('Get Audio Tracks'), + ), + const SizedBox(height: 16), + if (_audioTracks.isNotEmpty) ...[ + const Text( + 'Available Audio Tracks:', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + ...(_audioTracks.map((VideoAudioTrack track) => Card( + margin: const EdgeInsets.symmetric(vertical: 4), + child: ListTile( + leading: CircleAvatar( + backgroundColor: + track.isSelected ? Colors.green : Colors.grey, + child: Icon( + track.isSelected ? Icons.check : Icons.audiotrack, + color: Colors.white, + size: 16, + ), + ), + title: Text( + track.label, + style: TextStyle( + fontWeight: + track.isSelected ? FontWeight.bold : FontWeight.normal, + ), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Language: ${track.language} | ID: ${track.id}'), + if (track.bitrate != null || + track.sampleRate != null || + track.channelCount != null || + track.codec != null) + Text( + _formatQualityInfo(track), + style: const TextStyle( + fontSize: 12, + color: Colors.blue, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + trailing: track.isSelected + ? const Chip( + label: Text('Selected', style: TextStyle(fontSize: 12)), + backgroundColor: Colors.green, + labelStyle: TextStyle(color: Colors.white), + ) + : null, + ), + ))), + ] else if (_audioTracks.isEmpty && !_isLoadingTracks) ...[ + const Text( + 'No audio tracks found. Click "Get Audio Tracks" to retrieve them.', + style: TextStyle(color: Colors.grey), + textAlign: TextAlign.center, + ), + ], + ], + ), + ), ], ), ); @@ -484,8 +602,7 @@ class _PlayerVideoAndPopPageState extends State<_PlayerVideoAndPopPage> { void initState() { super.initState(); - _videoPlayerController = - VideoPlayerController.asset('assets/Butterfly-209.mp4'); + _videoPlayerController = VideoPlayerController.asset('assets/Butterfly-209.mp4'); _videoPlayerController.addListener(() { if (startedPlaying && !_videoPlayerController.value.isPlaying) { Navigator.pop(context); diff --git a/packages/video_player/video_player/example/pubspec.yaml b/packages/video_player/video_player/example/pubspec.yaml index d4ed8773a5b..cca2ea88af6 100644 --- a/packages/video_player/video_player/example/pubspec.yaml +++ b/packages/video_player/video_player/example/pubspec.yaml @@ -35,3 +35,9 @@ flutter: - assets/bumble_bee_captions.srt - assets/bumble_bee_captions.vtt - assets/Audio.mp3 +# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE. +# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins +dependency_overrides: + video_player_android: {path: ../../../../packages/video_player/video_player_android} + video_player_avfoundation: {path: ../../../../packages/video_player/video_player_avfoundation} + video_player_platform_interface: {path: ../../../../packages/video_player/video_player_platform_interface} diff --git a/packages/video_player/video_player/lib/video_player.dart b/packages/video_player/video_player/lib/video_player.dart index 17c5bcb2995..1c1735b78f5 100644 --- a/packages/video_player/video_player/lib/video_player.dart +++ b/packages/video_player/video_player/lib/video_player.dart @@ -16,6 +16,7 @@ export 'package:video_player_platform_interface/video_player_platform_interface. show DataSourceType, DurationRange, + VideoAudioTrack, VideoFormat, VideoPlayerOptions, VideoPlayerWebOptions, @@ -807,6 +808,20 @@ class VideoPlayerController extends ValueNotifier { ); } + /// Retrieves all available audio tracks for the current video. + /// + /// Returns a list of [VideoAudioTrack] objects containing information about + /// each audio track including id, label, language, and selection status. + /// + /// This method can only be called after the video has been initialized. + /// If called before initialization, it will return an empty list. + Future> getAudioTracks() async { + if (_isDisposedOrNotInitialized) { + return []; + } + return _videoPlayerPlatform.getAudioTracks(_playerId); + } + @override void removeListener(VoidCallback listener) { // Prevent VideoPlayer from causing an exception to be thrown when attempting to diff --git a/packages/video_player/video_player/pubspec.yaml b/packages/video_player/video_player/pubspec.yaml index 7ae423d6bdb..b0cd1f6466e 100644 --- a/packages/video_player/video_player/pubspec.yaml +++ b/packages/video_player/video_player/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for displaying inline video with other Flutter widgets on Android, iOS, macOS and web. repository: https://github.com/flutter/packages/tree/main/packages/video_player/video_player issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+video_player%22 -version: 2.10.0 +version: 2.11.0 environment: sdk: ^3.6.0 @@ -38,3 +38,9 @@ dev_dependencies: topics: - video - video-player +# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE. +# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins +dependency_overrides: + video_player_android: {path: ../../../packages/video_player/video_player_android} + video_player_avfoundation: {path: ../../../packages/video_player/video_player_avfoundation} + video_player_platform_interface: {path: ../../../packages/video_player/video_player_platform_interface} 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 38acf159dd0..dbba674da79 100644 --- a/packages/video_player/video_player/test/video_player_test.dart +++ b/packages/video_player/video_player/test/video_player_test.dart @@ -84,10 +84,14 @@ class FakeController extends ValueNotifier Future setClosedCaptionFile( Future? closedCaptionFile, ) async {} + + @override + Future> getAudioTracks() async { + return []; + } } -Future _loadClosedCaption() async => - _FakeClosedCaptionFile(); +Future _loadClosedCaption() async => _FakeClosedCaptionFile(); class _FakeClosedCaptionFile extends ClosedCaptionFile { @override @@ -122,11 +126,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); } @@ -168,11 +170,9 @@ void main() { findsOneWidget); }); - testWidgets('non-zero rotationCorrection value is used', - (WidgetTester tester) async { + testWidgets('non-zero rotationCorrection value is used', (WidgetTester tester) async { final FakeController controller = FakeController.value( - const VideoPlayerValue( - duration: Duration.zero, rotationCorrection: 180)); + const VideoPlayerValue(duration: Duration.zero, rotationCorrection: 180)); addTearDown(controller.dispose); controller.playerId = 1; await tester.pumpWidget(VideoPlayer(controller)); @@ -195,8 +195,7 @@ void main() { group('ClosedCaption widget', () { testWidgets('uses a default text style', (WidgetTester tester) async { const String 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); @@ -228,8 +227,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 String text = 'foo'; await tester.pumpWidget(const MaterialApp( home: Scaffold( @@ -310,15 +308,13 @@ void main() { group('initialize', () { test('started app lifecycle observing', () async { - final VideoPlayerController controller = - VideoPlayerController.networkUrl( + final VideoPlayerController controller = VideoPlayerController.networkUrl( Uri.parse('https://127.0.0.1'), ); addTearDown(controller.dispose); await controller.initialize(); await controller.play(); - verifyPlayStateRespondsToLifecycle(controller, - shouldPlayInBackground: false); + verifyPlayStateRespondsToLifecycle(controller, shouldPlayInBackground: false); }); test('asset', () async { @@ -352,8 +348,7 @@ void main() { }); test('network url with hint', () async { - final VideoPlayerController controller = - VideoPlayerController.networkUrl( + final VideoPlayerController controller = VideoPlayerController.networkUrl( Uri.parse('https://127.0.0.1'), formatHint: VideoFormat.dash, ); @@ -375,8 +370,7 @@ void main() { }); test('network url with some headers', () async { - final VideoPlayerController controller = - VideoPlayerController.networkUrl( + final VideoPlayerController controller = VideoPlayerController.networkUrl( Uri.parse('https://127.0.0.1'), httpHeaders: {'Authorization': 'Bearer token'}, ); @@ -397,8 +391,7 @@ void main() { ); }); - test( - 'when controller is initialized with invalid url it should throw VideoError', + test('when controller is initialized with invalid url it should throw VideoError', () async { final Uri invalidUrl = Uri.parse('http://testing.com/invalid_url'); @@ -430,8 +423,7 @@ void main() { 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'); + 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 { @@ -451,8 +443,7 @@ void main() { ); }, skip: kIsWeb /* Web does not support file assets. */); - test('successful initialize on controller with error clears error', - () async { + test('successful initialize on controller with error clears error', () async { final VideoPlayerController controller = VideoPlayerController.network( 'https://127.0.0.1', ); @@ -527,9 +518,7 @@ void main() { // The two last calls will be "play" and then "setPlaybackSpeed". The // reason for this is that "play" calls "setPlaybackSpeed" internally. - expect( - fakeVideoPlayerPlatform - .calls[fakeVideoPlayerPlatform.calls.length - 2], + expect(fakeVideoPlayerPlatform.calls[fakeVideoPlayerPlatform.calls.length - 2], 'play'); expect(fakeVideoPlayerPlatform.calls.last, 'setPlaybackSpeed'); }); @@ -692,8 +681,7 @@ 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 VideoPlayerController controller = VideoPlayerController.networkUrl(_localhostUri); @@ -720,8 +708,7 @@ 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 VideoPlayerController controller = VideoPlayerController.networkUrl(_localhostUri); @@ -749,8 +736,7 @@ void main() { group('caption', () { test('works when position updates', () async { - final VideoPlayerController controller = - VideoPlayerController.networkUrl( + final VideoPlayerController controller = VideoPlayerController.networkUrl( _localhostUri, closedCaptionFile: _loadClosedCaption(), ); @@ -788,8 +774,7 @@ void main() { }); test('works when seeking', () async { - final VideoPlayerController controller = - VideoPlayerController.networkUrl( + final VideoPlayerController controller = VideoPlayerController.networkUrl( _localhostUri, closedCaptionFile: _loadClosedCaption(), ); @@ -822,8 +807,7 @@ void main() { }); test('works when seeking with captionOffset positive', () async { - final VideoPlayerController controller = - VideoPlayerController.networkUrl( + final VideoPlayerController controller = VideoPlayerController.networkUrl( _localhostUri, closedCaptionFile: _loadClosedCaption(), ); @@ -860,8 +844,7 @@ void main() { }); test('works when seeking with captionOffset negative', () async { - final VideoPlayerController controller = - VideoPlayerController.networkUrl( + final VideoPlayerController controller = VideoPlayerController.networkUrl( _localhostUri, closedCaptionFile: _loadClosedCaption(), ); @@ -901,8 +884,7 @@ void main() { }); test('setClosedCaptionFile loads caption file', () async { - final VideoPlayerController controller = - VideoPlayerController.networkUrl( + final VideoPlayerController controller = VideoPlayerController.networkUrl( _localhostUri, ); addTearDown(controller.dispose); @@ -918,8 +900,7 @@ void main() { }); test('setClosedCaptionFile removes/changes caption file', () async { - final VideoPlayerController controller = - VideoPlayerController.networkUrl( + final VideoPlayerController controller = VideoPlayerController.networkUrl( _localhostUri, closedCaptionFile: _loadClosedCaption(), ); @@ -938,8 +919,7 @@ void main() { group('Platform callbacks', () { testWidgets('playing completed', (WidgetTester tester) async { - final VideoPlayerController controller = - VideoPlayerController.networkUrl( + final VideoPlayerController controller = VideoPlayerController.networkUrl( _localhostUri, ); @@ -952,8 +932,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); @@ -987,8 +966,7 @@ void main() { }); testWidgets('buffering status', (WidgetTester tester) async { - final VideoPlayerController controller = - VideoPlayerController.networkUrl( + final VideoPlayerController controller = VideoPlayerController.networkUrl( _localhostUri, ); @@ -998,8 +976,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); @@ -1016,8 +993,7 @@ void main() { expect(controller.value.buffered[0].toString(), 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); @@ -1130,8 +1106,8 @@ void main() { const Duration duration = Duration(seconds: 5); const Size size = Size(400, 300); const Duration position = Duration(seconds: 1); - const Caption caption = Caption( - text: 'foo', number: 0, start: Duration.zero, end: Duration.zero); + const Caption caption = + Caption(text: 'foo', number: 0, start: Duration.zero, end: Duration.zero); const Duration captionOffset = Duration(milliseconds: 250); final List buffered = [ DurationRange(Duration.zero, const Duration(seconds: 4)) @@ -1191,15 +1167,13 @@ void main() { }); test('errorDescription is changed when copy with another error', () { const VideoPlayerValue 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 VideoPlayerValue original = VideoPlayerValue.uninitialized(); - final VideoPlayerValue copy = - original.copyWith(errorDescription: 'new error'); + final VideoPlayerValue copy = original.copyWith(errorDescription: 'new error'); expect(copy.errorDescription, 'new error'); }); @@ -1365,8 +1339,7 @@ void main() { isCompletedTest(); if (!hasLooped) { fakeVideoEventStream.add(VideoEvent( - eventType: VideoEventType.isPlayingStateUpdate, - isPlaying: true)); + eventType: VideoEventType.isPlayingStateUpdate, isPlaying: true)); hasLooped = !hasLooped; } } else { @@ -1391,8 +1364,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) { @@ -1409,6 +1381,119 @@ void main() { await controller.seekTo(const Duration(seconds: 20)); }); + + group('getAudioTracks', () { + test('returns audio tracks with metadata', () async { + final VideoPlayerController controller = VideoPlayerController.networkUrl( + Uri.parse('https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_16x9/bipbop_16x9_variant.m3u8'), + videoPlayerOptions: VideoPlayerOptions(), + ); + addTearDown(controller.dispose); + + await controller.initialize(); + + final List audioTracks = await controller.getAudioTracks(); + + expect(audioTracks, hasLength(2)); + expect(fakeVideoPlayerPlatform.calls, contains('getAudioTracks')); + + // Test first track (BipBop Audio 1 - selected) + final VideoAudioTrack firstTrack = audioTracks[0]; + expect(firstTrack.id, 'hls_audio_0'); + expect(firstTrack.label, 'BipBop Audio 1'); + expect(firstTrack.language, 'eng'); + expect(firstTrack.isSelected, true); + expect(firstTrack.bitrate, null); + expect(firstTrack.sampleRate, null); + expect(firstTrack.channelCount, null); + expect(firstTrack.codec, null); + + // Test second track (BipBop Audio 2 - unselected) + final VideoAudioTrack secondTrack = audioTracks[1]; + expect(secondTrack.id, 'hls_audio_1'); + expect(secondTrack.label, 'BipBop Audio 2'); + expect(secondTrack.language, 'eng'); + expect(secondTrack.isSelected, false); + expect(secondTrack.bitrate, null); + expect(secondTrack.sampleRate, null); + expect(secondTrack.channelCount, null); + expect(secondTrack.codec, null); + }); + + + + test('VideoAudioTrack equality works correctly', () { + const VideoAudioTrack track1 = VideoAudioTrack( + id: 'test', + label: 'Test Track', + language: 'en', + bitrate: 128000, + sampleRate: 48000, + channelCount: 2, + codec: 'aac', + ); + + const VideoAudioTrack track2 = VideoAudioTrack( + id: 'test', + label: 'Test Track', + language: 'en', + bitrate: 128000, + sampleRate: 48000, + channelCount: 2, + codec: 'aac', + ); + + const VideoAudioTrack track3 = VideoAudioTrack( + id: 'different', + label: 'Test Track', + language: 'en', + bitrate: 128000, + sampleRate: 48000, + channelCount: 2, + codec: 'aac', + ); + + expect(track1, equals(track2)); + expect(track1, isNot(equals(track3))); + expect(track1.hashCode, equals(track2.hashCode)); + expect(track1.hashCode, isNot(equals(track3.hashCode))); + }); + + test('VideoAudioTrack toString includes all fields', () { + const VideoAudioTrack track = VideoAudioTrack( + id: 'test_id', + label: 'Test Label', + language: 'en', + isSelected: true, + bitrate: 128000, + sampleRate: 48000, + channelCount: 2, + codec: 'aac', + ); + + final String trackString = track.toString(); + expect(trackString, contains('test_id')); + expect(trackString, contains('Test Label')); + expect(trackString, contains('en')); + expect(trackString, contains('true')); + expect(trackString, contains('128000')); + expect(trackString, contains('48000')); + expect(trackString, contains('2')); + expect(trackString, contains('aac')); + }); + + test('getAudioTracks returns empty list when controller not initialized', () async { + final VideoPlayerController controller = VideoPlayerController.networkUrl( + _localhostUri, + videoPlayerOptions: VideoPlayerOptions(), + ); + addTearDown(controller.dispose); + + // Don't initialize the controller + final List audioTracks = await controller.getAudioTracks(); + expect(audioTracks, isEmpty); + }); + }); } class FakeVideoPlayerPlatform extends VideoPlayerPlatform { @@ -1421,8 +1506,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 { @@ -1430,8 +1514,8 @@ class FakeVideoPlayerPlatform extends VideoPlayerPlatform { final StreamController stream = StreamController(); streams[nextPlayerId] = stream; if (forceInitError) { - stream.addError(PlatformException( - code: 'VideoError', message: 'Video player had error XYZ')); + stream.addError( + PlatformException(code: 'VideoError', message: 'Video player had error XYZ')); } else { stream.add(VideoEvent( eventType: VideoEventType.initialized, @@ -1448,8 +1532,8 @@ class FakeVideoPlayerPlatform extends VideoPlayerPlatform { final StreamController stream = StreamController(); streams[nextPlayerId] = stream; if (forceInitError) { - stream.addError(PlatformException( - code: 'VideoError', message: 'Video player had error XYZ')); + stream.addError( + PlatformException(code: 'VideoError', message: 'Video player had error XYZ')); } else { stream.add(VideoEvent( eventType: VideoEventType.initialized, @@ -1525,12 +1609,30 @@ 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; } + + @override + Future> getAudioTracks(int playerId) async { + calls.add('getAudioTracks'); + // Return mock audio tracks matching Apple's bipbop HLS stream + return [ + const VideoAudioTrack( + id: 'hls_audio_0', + label: 'BipBop Audio 1', + language: 'eng', + isSelected: true, + ), + const VideoAudioTrack( + id: 'hls_audio_1', + label: 'BipBop Audio 2', + language: 'eng', + ), + ]; + } } diff --git a/packages/video_player/video_player_android/CHANGELOG.md b/packages/video_player/video_player_android/CHANGELOG.md index e21943e4a63..513f73efbbc 100644 --- a/packages/video_player/video_player_android/CHANGELOG.md +++ b/packages/video_player/video_player_android/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.9.0 + +* Adds audio track metadata support including bitrate, sample rate, channel count, and codec information. + ## 2.8.11 * Updates kotlin version to 2.2.0 to enable gradle 8.11 support. diff --git a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/Messages.java b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/Messages.java index c98f787d318..7defef18486 100644 --- a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/Messages.java +++ b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/Messages.java @@ -21,6 +21,7 @@ import java.lang.annotation.Target; import java.nio.ByteBuffer; import java.util.ArrayList; +import java.util.List; import java.util.Map; import java.util.Objects; @@ -324,6 +325,248 @@ ArrayList toList() { } } + /** + * Represents an audio track in a video. + * + *

Generated class from Pigeon that represents data sent in messages. + */ + public static final class AudioTrackMessage { + private @NonNull String id; + + public @NonNull String getId() { + return id; + } + + public void setId(@NonNull String setterArg) { + if (setterArg == null) { + throw new IllegalStateException("Nonnull field \"id\" is null."); + } + this.id = setterArg; + } + + private @NonNull String label; + + public @NonNull String getLabel() { + return label; + } + + public void setLabel(@NonNull String setterArg) { + if (setterArg == null) { + throw new IllegalStateException("Nonnull field \"label\" is null."); + } + this.label = setterArg; + } + + private @NonNull String language; + + public @NonNull String getLanguage() { + return language; + } + + public void setLanguage(@NonNull String setterArg) { + if (setterArg == null) { + throw new IllegalStateException("Nonnull field \"language\" is null."); + } + this.language = setterArg; + } + + private @NonNull Boolean isSelected; + + public @NonNull Boolean getIsSelected() { + return isSelected; + } + + public void setIsSelected(@NonNull Boolean setterArg) { + if (setterArg == null) { + throw new IllegalStateException("Nonnull field \"isSelected\" is null."); + } + this.isSelected = setterArg; + } + + private @Nullable Long bitrate; + + public @Nullable Long getBitrate() { + return bitrate; + } + + public void setBitrate(@Nullable Long setterArg) { + this.bitrate = setterArg; + } + + private @Nullable Long sampleRate; + + public @Nullable Long getSampleRate() { + return sampleRate; + } + + public void setSampleRate(@Nullable Long setterArg) { + this.sampleRate = setterArg; + } + + private @Nullable Long channelCount; + + public @Nullable Long getChannelCount() { + return channelCount; + } + + public void setChannelCount(@Nullable Long setterArg) { + this.channelCount = setterArg; + } + + private @Nullable String codec; + + public @Nullable String getCodec() { + return codec; + } + + public void setCodec(@Nullable String setterArg) { + this.codec = setterArg; + } + + /** Constructor is non-public to enforce null safety; use Builder. */ + AudioTrackMessage() {} + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + AudioTrackMessage that = (AudioTrackMessage) o; + return id.equals(that.id) + && label.equals(that.label) + && language.equals(that.language) + && isSelected.equals(that.isSelected) + && Objects.equals(bitrate, that.bitrate) + && Objects.equals(sampleRate, that.sampleRate) + && Objects.equals(channelCount, that.channelCount) + && Objects.equals(codec, that.codec); + } + + @Override + public int hashCode() { + return Objects.hash( + id, label, language, isSelected, bitrate, sampleRate, channelCount, codec); + } + + public static final class Builder { + + private @Nullable String id; + + @CanIgnoreReturnValue + public @NonNull Builder setId(@NonNull String setterArg) { + this.id = setterArg; + return this; + } + + private @Nullable String label; + + @CanIgnoreReturnValue + public @NonNull Builder setLabel(@NonNull String setterArg) { + this.label = setterArg; + return this; + } + + private @Nullable String language; + + @CanIgnoreReturnValue + public @NonNull Builder setLanguage(@NonNull String setterArg) { + this.language = setterArg; + return this; + } + + private @Nullable Boolean isSelected; + + @CanIgnoreReturnValue + public @NonNull Builder setIsSelected(@NonNull Boolean setterArg) { + this.isSelected = setterArg; + return this; + } + + private @Nullable Long bitrate; + + @CanIgnoreReturnValue + public @NonNull Builder setBitrate(@Nullable Long setterArg) { + this.bitrate = setterArg; + return this; + } + + private @Nullable Long sampleRate; + + @CanIgnoreReturnValue + public @NonNull Builder setSampleRate(@Nullable Long setterArg) { + this.sampleRate = setterArg; + return this; + } + + private @Nullable Long channelCount; + + @CanIgnoreReturnValue + public @NonNull Builder setChannelCount(@Nullable Long setterArg) { + this.channelCount = setterArg; + return this; + } + + private @Nullable String codec; + + @CanIgnoreReturnValue + public @NonNull Builder setCodec(@Nullable String setterArg) { + this.codec = setterArg; + return this; + } + + public @NonNull AudioTrackMessage build() { + AudioTrackMessage pigeonReturn = new AudioTrackMessage(); + pigeonReturn.setId(id); + pigeonReturn.setLabel(label); + pigeonReturn.setLanguage(language); + pigeonReturn.setIsSelected(isSelected); + pigeonReturn.setBitrate(bitrate); + pigeonReturn.setSampleRate(sampleRate); + pigeonReturn.setChannelCount(channelCount); + pigeonReturn.setCodec(codec); + return pigeonReturn; + } + } + + @NonNull + ArrayList toList() { + ArrayList toListResult = new ArrayList<>(8); + toListResult.add(id); + toListResult.add(label); + toListResult.add(language); + toListResult.add(isSelected); + toListResult.add(bitrate); + toListResult.add(sampleRate); + toListResult.add(channelCount); + toListResult.add(codec); + return toListResult; + } + + static @NonNull AudioTrackMessage fromList(@NonNull ArrayList pigeonVar_list) { + AudioTrackMessage pigeonResult = new AudioTrackMessage(); + Object id = pigeonVar_list.get(0); + pigeonResult.setId((String) id); + Object label = pigeonVar_list.get(1); + pigeonResult.setLabel((String) label); + Object language = pigeonVar_list.get(2); + pigeonResult.setLanguage((String) language); + Object isSelected = pigeonVar_list.get(3); + pigeonResult.setIsSelected((Boolean) isSelected); + Object bitrate = pigeonVar_list.get(4); + pigeonResult.setBitrate((Long) bitrate); + Object sampleRate = pigeonVar_list.get(5); + pigeonResult.setSampleRate((Long) sampleRate); + Object channelCount = pigeonVar_list.get(6); + pigeonResult.setChannelCount((Long) channelCount); + Object codec = pigeonVar_list.get(7); + pigeonResult.setCodec((String) codec); + return pigeonResult; + } + } + private static class PigeonCodec extends StandardMessageCodec { public static final PigeonCodec INSTANCE = new PigeonCodec(); @@ -346,6 +589,8 @@ protected Object readValueOfType(byte type, @NonNull ByteBuffer buffer) { return PlatformVideoViewCreationParams.fromList((ArrayList) readValue(buffer)); case (byte) 132: return CreateMessage.fromList((ArrayList) readValue(buffer)); + case (byte) 133: + return AudioTrackMessage.fromList((ArrayList) readValue(buffer)); default: return super.readValueOfType(type, buffer); } @@ -365,6 +610,9 @@ protected void writeValue(@NonNull ByteArrayOutputStream stream, Object value) { } else if (value instanceof CreateMessage) { stream.write(132); writeValue(stream, ((CreateMessage) value).toList()); + } else if (value instanceof AudioTrackMessage) { + stream.write(133); + writeValue(stream, ((AudioTrackMessage) value).toList()); } else { super.writeValue(stream, value); } @@ -548,6 +796,9 @@ public interface VideoPlayerInstanceApi { void pause(); + @NonNull + List getAudioTracks(); + /** The codec used by VideoPlayerInstanceApi. */ static @NonNull MessageCodec getCodec() { return PigeonCodec.INSTANCE; @@ -735,6 +986,29 @@ static void setUp( channel.setMessageHandler(null); } } + { + BasicMessageChannel channel = + new BasicMessageChannel<>( + binaryMessenger, + "dev.flutter.pigeon.video_player_android.VideoPlayerInstanceApi.getAudioTracks" + + messageChannelSuffix, + getCodec()); + if (api != null) { + channel.setMessageHandler( + (message, reply) -> { + ArrayList wrapped = new ArrayList<>(); + try { + List output = api.getAudioTracks(); + wrapped.add(0, output); + } catch (Throwable exception) { + wrapped = wrapError(exception); + } + reply.reply(wrapped); + }); + } else { + channel.setMessageHandler(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 2c4876de6e0..4e12e9a8275 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 @@ -11,10 +11,14 @@ import androidx.annotation.Nullable; import androidx.media3.common.AudioAttributes; import androidx.media3.common.C; +import androidx.media3.common.Format; import androidx.media3.common.MediaItem; import androidx.media3.common.PlaybackParameters; +import androidx.media3.common.Tracks; import androidx.media3.exoplayer.ExoPlayer; import io.flutter.view.TextureRegistry.SurfaceProducer; +import java.util.ArrayList; +import java.util.List; /** * A class responsible for managing video playback using {@link ExoPlayer}. @@ -125,6 +129,46 @@ public ExoPlayer getExoPlayer() { return exoPlayer; } + @Override + public @NonNull List getAudioTracks() { + List audioTracks = 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 audio tracks + if (group.getType() == C.TRACK_TYPE_AUDIO) { + for (int trackIndex = 0; trackIndex < group.length; trackIndex++) { + Format format = group.getTrackFormat(trackIndex); + boolean isSelected = group.isTrackSelected(trackIndex); + + // Create AudioTrackMessage with metadata + Messages.AudioTrackMessage audioTrack = + new Messages.AudioTrackMessage.Builder() + .setId(String.valueOf(groupIndex) + "_" + String.valueOf(trackIndex)) + .setLabel(format.label != null ? format.label : "Audio Track " + (trackIndex + 1)) + .setLanguage(format.language != null ? format.language : "und") + .setIsSelected(isSelected) + .setBitrate(format.bitrate != Format.NO_VALUE ? (long) format.bitrate : null) + .setSampleRate( + format.sampleRate != Format.NO_VALUE ? (long) format.sampleRate : null) + .setChannelCount( + format.channelCount != Format.NO_VALUE ? (long) format.channelCount : null) + .setCodec(format.codecs != null ? format.codecs : null) + .build(); + + audioTracks.add(audioTrack); + } + } + } + + return audioTracks; + } + public void dispose() { if (disposeHandler != null) { disposeHandler.onDispose(); diff --git a/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/AudioTracksTest.java b/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/AudioTracksTest.java new file mode 100644 index 00000000000..e0896f1a7b9 --- /dev/null +++ b/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/AudioTracksTest.java @@ -0,0 +1,336 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.videoplayer; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +import androidx.media3.common.C; +import androidx.media3.common.Format; +import androidx.media3.common.Tracks; +import androidx.media3.exoplayer.ExoPlayer; +import io.flutter.view.TextureRegistry; +import java.util.List; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; + +@RunWith(RobolectricTestRunner.class) +public class AudioTracksTest { + + @Mock private ExoPlayer mockExoPlayer; + @Mock private VideoPlayerCallbacks mockVideoPlayerCallbacks; + @Mock private TextureRegistry.SurfaceProducer mockSurfaceProducer; + @Mock private Tracks mockTracks; + @Mock private Tracks.Group mockAudioGroup1; + @Mock private Tracks.Group mockAudioGroup2; + @Mock private Tracks.Group mockVideoGroup; + + private VideoPlayer videoPlayer; + + @Before + public void setUp() { + MockitoAnnotations.openMocks(this); + + // Create a concrete VideoPlayer implementation for testing + videoPlayer = new VideoPlayer( + mockVideoPlayerCallbacks, + mockSurfaceProducer, + () -> mockExoPlayer + ) {}; + } + + @Test + public void testGetAudioTracks_withMultipleAudioTracks() { + // Create mock formats for audio tracks + Format audioFormat1 = new Format.Builder() + .setId("audio_track_1") + .setLabel("English") + .setLanguage("en") + .setBitrate(128000) + .setSampleRate(48000) + .setChannelCount(2) + .setCodecs("mp4a.40.2") + .build(); + + Format audioFormat2 = new Format.Builder() + .setId("audio_track_2") + .setLabel("Español") + .setLanguage("es") + .setBitrate(96000) + .setSampleRate(44100) + .setChannelCount(2) + .setCodecs("mp4a.40.2") + .build(); + + // Mock audio groups + when(mockAudioGroup1.getType()).thenReturn(C.TRACK_TYPE_AUDIO); + when(mockAudioGroup1.length()).thenReturn(1); + when(mockAudioGroup1.getTrackFormat(0)).thenReturn(audioFormat1); + when(mockAudioGroup1.isTrackSelected(0)).thenReturn(true); + + when(mockAudioGroup2.getType()).thenReturn(C.TRACK_TYPE_AUDIO); + when(mockAudioGroup2.length()).thenReturn(1); + when(mockAudioGroup2.getTrackFormat(0)).thenReturn(audioFormat2); + when(mockAudioGroup2.isTrackSelected(0)).thenReturn(false); + + // Mock video group (should be ignored) + when(mockVideoGroup.getType()).thenReturn(C.TRACK_TYPE_VIDEO); + + // Mock tracks + List groups = List.of(mockAudioGroup1, mockAudioGroup2, mockVideoGroup); + when(mockTracks.getGroups()).thenReturn(groups); + when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks); + + // Test the method + List result = videoPlayer.getAudioTracks(); + + // Verify results + assertNotNull(result); + assertEquals(2, result.size()); + + // Verify first track + Messages.AudioTrackMessage track1 = result.get(0); + assertEquals("0_0", track1.getId()); + assertEquals("English", track1.getLabel()); + assertEquals("en", track1.getLanguage()); + assertTrue(track1.getIsSelected()); + assertEquals(Long.valueOf(128000), track1.getBitrate()); + assertEquals(Long.valueOf(48000), track1.getSampleRate()); + assertEquals(Long.valueOf(2), track1.getChannelCount()); + assertEquals("mp4a.40.2", track1.getCodec()); + + // Verify second track + Messages.AudioTrackMessage track2 = result.get(1); + assertEquals("1_0", track2.getId()); + assertEquals("Español", track2.getLabel()); + assertEquals("es", track2.getLanguage()); + assertFalse(track2.getIsSelected()); + assertEquals(Long.valueOf(96000), track2.getBitrate()); + assertEquals(Long.valueOf(44100), track2.getSampleRate()); + assertEquals(Long.valueOf(2), track2.getChannelCount()); + assertEquals("mp4a.40.2", track2.getCodec()); + } + + @Test + public void testGetAudioTracks_withNoAudioTracks() { + // Mock video group only (no audio tracks) + when(mockVideoGroup.getType()).thenReturn(C.TRACK_TYPE_VIDEO); + + List groups = List.of(mockVideoGroup); + when(mockTracks.getGroups()).thenReturn(groups); + when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks); + + // Test the method + List result = videoPlayer.getAudioTracks(); + + // Verify results + assertNotNull(result); + assertEquals(0, result.size()); + } + + @Test + public void testGetAudioTracks_withNullValues() { + // Create format with null/missing values + Format audioFormat = new Format.Builder() + .setId("audio_track_null") + .setLabel(null) // Null label + .setLanguage(null) // Null language + .setBitrate(Format.NO_VALUE) // No bitrate + .setSampleRate(Format.NO_VALUE) // No sample rate + .setChannelCount(Format.NO_VALUE) // No channel count + .setCodecs(null) // Null codec + .build(); + + // Mock audio group + when(mockAudioGroup1.getType()).thenReturn(C.TRACK_TYPE_AUDIO); + when(mockAudioGroup1.length()).thenReturn(1); + when(mockAudioGroup1.getTrackFormat(0)).thenReturn(audioFormat); + when(mockAudioGroup1.isTrackSelected(0)).thenReturn(false); + + List groups = List.of(mockAudioGroup1); + when(mockTracks.getGroups()).thenReturn(groups); + when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks); + + // Test the method + List result = videoPlayer.getAudioTracks(); + + // Verify results + assertNotNull(result); + assertEquals(1, result.size()); + + Messages.AudioTrackMessage track = result.get(0); + assertEquals("0_0", track.getId()); + assertEquals("Audio Track 1", track.getLabel()); // Fallback label + assertEquals("und", track.getLanguage()); // Fallback language + assertFalse(track.getIsSelected()); + assertNull(track.getBitrate()); + assertNull(track.getSampleRate()); + assertNull(track.getChannelCount()); + assertNull(track.getCodec()); + } + + @Test + public void testGetAudioTracks_withMultipleTracksInSameGroup() { + // Create format for group with multiple tracks + Format audioFormat1 = new Format.Builder() + .setId("audio_track_1") + .setLabel("Track 1") + .setLanguage("en") + .setBitrate(128000) + .build(); + + Format audioFormat2 = new Format.Builder() + .setId("audio_track_2") + .setLabel("Track 2") + .setLanguage("en") + .setBitrate(192000) + .build(); + + // Mock audio group with multiple tracks + when(mockAudioGroup1.getType()).thenReturn(C.TRACK_TYPE_AUDIO); + when(mockAudioGroup1.length()).thenReturn(2); + when(mockAudioGroup1.getTrackFormat(0)).thenReturn(audioFormat1); + when(mockAudioGroup1.getTrackFormat(1)).thenReturn(audioFormat2); + when(mockAudioGroup1.isTrackSelected(0)).thenReturn(true); + when(mockAudioGroup1.isTrackSelected(1)).thenReturn(false); + + List groups = List.of(mockAudioGroup1); + when(mockTracks.getGroups()).thenReturn(groups); + when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks); + + // Test the method + List result = videoPlayer.getAudioTracks(); + + // Verify results + assertNotNull(result); + assertEquals(2, result.size()); + + // Verify track IDs are unique + Messages.AudioTrackMessage track1 = result.get(0); + Messages.AudioTrackMessage track2 = result.get(1); + assertEquals("0_0", track1.getId()); + assertEquals("0_1", track2.getId()); + assertNotEquals(track1.getId(), track2.getId()); + } + + @Test + public void testGetAudioTracks_withDifferentCodecs() { + // Test various codec formats + Format aacFormat = new Format.Builder() + .setCodecs("mp4a.40.2") + .setLabel("AAC Track") + .build(); + + Format ac3Format = new Format.Builder() + .setCodecs("ac-3") + .setLabel("AC3 Track") + .build(); + + Format eac3Format = new Format.Builder() + .setCodecs("ec-3") + .setLabel("EAC3 Track") + .build(); + + // Mock audio groups + when(mockAudioGroup1.getType()).thenReturn(C.TRACK_TYPE_AUDIO); + when(mockAudioGroup1.length()).thenReturn(3); + when(mockAudioGroup1.getTrackFormat(0)).thenReturn(aacFormat); + when(mockAudioGroup1.getTrackFormat(1)).thenReturn(ac3Format); + when(mockAudioGroup1.getTrackFormat(2)).thenReturn(eac3Format); + when(mockAudioGroup1.isTrackSelected(anyInt())).thenReturn(false); + + List groups = List.of(mockAudioGroup1); + when(mockTracks.getGroups()).thenReturn(groups); + when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks); + + // Test the method + List result = videoPlayer.getAudioTracks(); + + // Verify results + assertNotNull(result); + assertEquals(3, result.size()); + + assertEquals("mp4a.40.2", result.get(0).getCodec()); + assertEquals("ac-3", result.get(1).getCodec()); + assertEquals("ec-3", result.get(2).getCodec()); + } + + @Test + public void testGetAudioTracks_withHighBitrateValues() { + // Test with high bitrate values + Format highBitrateFormat = new Format.Builder() + .setId("high_bitrate_track") + .setLabel("High Quality") + .setBitrate(1536000) // 1.5 Mbps + .setSampleRate(96000) // 96 kHz + .setChannelCount(8) // 7.1 surround + .build(); + + when(mockAudioGroup1.getType()).thenReturn(C.TRACK_TYPE_AUDIO); + when(mockAudioGroup1.length()).thenReturn(1); + when(mockAudioGroup1.getTrackFormat(0)).thenReturn(highBitrateFormat); + when(mockAudioGroup1.isTrackSelected(0)).thenReturn(true); + + List groups = List.of(mockAudioGroup1); + when(mockTracks.getGroups()).thenReturn(groups); + when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks); + + // Test the method + List result = videoPlayer.getAudioTracks(); + + // Verify results + assertNotNull(result); + assertEquals(1, result.size()); + + Messages.AudioTrackMessage track = result.get(0); + assertEquals(Long.valueOf(1536000), track.getBitrate()); + assertEquals(Long.valueOf(96000), track.getSampleRate()); + assertEquals(Long.valueOf(8), track.getChannelCount()); + } + + @Test + public void testGetAudioTracks_performanceWithManyTracks() { + // Test performance with many audio tracks + int numGroups = 50; + List groups = new java.util.ArrayList<>(); + + for (int i = 0; i < numGroups; i++) { + Tracks.Group mockGroup = mock(Tracks.Group.class); + when(mockGroup.getType()).thenReturn(C.TRACK_TYPE_AUDIO); + when(mockGroup.length()).thenReturn(1); + + Format format = new Format.Builder() + .setId("track_" + i) + .setLabel("Track " + i) + .setLanguage("en") + .build(); + + when(mockGroup.getTrackFormat(0)).thenReturn(format); + when(mockGroup.isTrackSelected(0)).thenReturn(i == 0); // Only first track selected + + groups.add(mockGroup); + } + + when(mockTracks.getGroups()).thenReturn(groups); + when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks); + + // Measure performance + long startTime = System.currentTimeMillis(); + List result = videoPlayer.getAudioTracks(); + long endTime = System.currentTimeMillis(); + + // Verify results + assertNotNull(result); + assertEquals(numGroups, result.size()); + + // Should complete within reasonable time (1 second for 50 tracks) + assertTrue("getAudioTracks took too long: " + (endTime - startTime) + "ms", + (endTime - startTime) < 1000); + } +} diff --git a/packages/video_player/video_player_android/example/pubspec.yaml b/packages/video_player/video_player_android/example/pubspec.yaml index 286f6b89e69..fb2e387c7ee 100644 --- a/packages/video_player/video_player_android/example/pubspec.yaml +++ b/packages/video_player/video_player_android/example/pubspec.yaml @@ -34,3 +34,7 @@ flutter: assets: - assets/flutter-mark-square-64.png - assets/Butterfly-209.mp4 +# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE. +# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins +dependency_overrides: + video_player_platform_interface: {path: ../../../../packages/video_player/video_player_platform_interface} 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 fb07e03f2a6..f0aeb730a0b 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 @@ -247,6 +247,25 @@ class AndroidVideoPlayer extends VideoPlayerPlatform { return _api.setMixWithOthers(mixWithOthers); } + @override + Future> getAudioTracks(int playerId) async { + final VideoPlayerInstanceApi player = _playerWith(id: playerId); + final List audioTracks = await player.getAudioTracks(); + + return audioTracks.map((AudioTrackMessage track) { + return VideoAudioTrack( + id: track.id, + label: track.label, + language: track.language, + isSelected: track.isSelected, + bitrate: track.bitrate, + sampleRate: track.sampleRate, + channelCount: track.channelCount, + codec: track.codec, + ); + }).toList(); + } + EventChannel _eventChannelFor(int playerId) { return EventChannel('flutter.io/videoPlayer/videoEvents$playerId'); } 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 e5921e61175..44adb3bb3c1 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 @@ -135,6 +135,83 @@ class CreateMessage { int get hashCode => Object.hashAll(_toList()); } +/// Represents an audio track in a video. +class AudioTrackMessage { + AudioTrackMessage({ + required this.id, + required this.label, + required this.language, + required this.isSelected, + this.bitrate, + this.sampleRate, + this.channelCount, + this.codec, + }); + + String id; + + String label; + + String language; + + bool isSelected; + + int? bitrate; + + int? sampleRate; + + int? channelCount; + + String? codec; + + List _toList() { + return [ + id, + label, + language, + isSelected, + bitrate, + sampleRate, + channelCount, + codec, + ]; + } + + Object encode() { + return _toList(); + } + + static AudioTrackMessage decode(Object result) { + result as List; + return AudioTrackMessage( + id: result[0]! as String, + label: result[1]! as String, + language: result[2]! as String, + isSelected: result[3]! as bool, + bitrate: result[4] as int?, + sampleRate: result[5] as int?, + channelCount: result[6] as int?, + codec: result[7] as String?, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! AudioTrackMessage || 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 @@ -154,6 +231,9 @@ class _PigeonCodec extends StandardMessageCodec { } else if (value is CreateMessage) { buffer.putUint8(132); writeValue(buffer, value.encode()); + } else if (value is AudioTrackMessage) { + buffer.putUint8(133); + writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); } @@ -172,6 +252,8 @@ class _PigeonCodec extends StandardMessageCodec { return PlatformVideoViewCreationParams.decode(readValue(buffer)!); case 132: return CreateMessage.decode(readValue(buffer)!); + case 133: + return AudioTrackMessage.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); } @@ -541,4 +623,35 @@ class VideoPlayerInstanceApi { return; } } + + Future> getAudioTracks() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.video_player_android.VideoPlayerInstanceApi.getAudioTracks$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 List?)! + .cast(); + } + } } diff --git a/packages/video_player/video_player_android/pigeons/messages.dart b/packages/video_player/video_player_android/pigeons/messages.dart index fc1f601bf29..2a2b1daa92f 100644 --- a/packages/video_player/video_player_android/pigeons/messages.dart +++ b/packages/video_player/video_player_android/pigeons/messages.dart @@ -35,6 +35,29 @@ class CreateMessage { PlatformVideoViewType? viewType; } +/// Represents an audio track in a video. +class AudioTrackMessage { + AudioTrackMessage({ + required this.id, + required this.label, + required this.language, + required this.isSelected, + this.bitrate, + this.sampleRate, + this.channelCount, + this.codec, + }); + + String id; + String label; + String language; + bool isSelected; + int? bitrate; + int? sampleRate; + int? channelCount; + String? codec; +} + @HostApi() abstract class AndroidVideoPlayerApi { void initialize(); @@ -53,4 +76,5 @@ abstract class VideoPlayerInstanceApi { int getPosition(); void seekTo(int position); void pause(); + List getAudioTracks(); } diff --git a/packages/video_player/video_player_android/pubspec.yaml b/packages/video_player/video_player_android/pubspec.yaml index a9e239057b8..2945beab958 100644 --- a/packages/video_player/video_player_android/pubspec.yaml +++ b/packages/video_player/video_player_android/pubspec.yaml @@ -2,7 +2,7 @@ name: video_player_android description: Android implementation of the video_player plugin. repository: https://github.com/flutter/packages/tree/main/packages/video_player/video_player_android issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+video_player%22 -version: 2.8.11 +version: 2.9.0 environment: sdk: ^3.7.0 @@ -32,3 +32,7 @@ dev_dependencies: topics: - video - video-player +# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE. +# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins +dependency_overrides: + video_player_platform_interface: {path: ../../../packages/video_player/video_player_platform_interface} 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 c544da8227c..dbbec267817 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 @@ -704,5 +704,268 @@ void main() { ]), ); }); + + group('getAudioTracks', () { + test('returns audio tracks with complete metadata', () async { + final ( + AndroidVideoPlayer player, + _, + MockVideoPlayerInstanceApi instanceApi, + ) = setUpMockPlayer(playerId: 1); + + final List mockTracks = [ + AudioTrackMessage( + id: 'track1', + label: 'English', + language: 'en', + isSelected: true, + bitrate: 128000, + sampleRate: 48000, + channelCount: 2, + codec: 'aac', + ), + AudioTrackMessage( + id: 'track2', + label: 'Spanish', + language: 'es', + isSelected: false, + bitrate: 96000, + sampleRate: 44100, + channelCount: 2, + codec: 'mp3', + ), + ]; + + when(instanceApi.getAudioTracks()).thenAnswer((_) async => mockTracks); + + final List tracks = await player.getAudioTracks(1); + + expect(tracks, hasLength(2)); + + expect(tracks[0].id, 'track1'); + expect(tracks[0].label, 'English'); + expect(tracks[0].language, 'en'); + expect(tracks[0].isSelected, true); + expect(tracks[0].bitrate, 128000); + expect(tracks[0].sampleRate, 48000); + expect(tracks[0].channelCount, 2); + expect(tracks[0].codec, 'aac'); + + expect(tracks[1].id, 'track2'); + expect(tracks[1].label, 'Spanish'); + expect(tracks[1].language, 'es'); + expect(tracks[1].isSelected, false); + expect(tracks[1].bitrate, 96000); + expect(tracks[1].sampleRate, 44100); + expect(tracks[1].channelCount, 2); + expect(tracks[1].codec, 'mp3'); + + verify(instanceApi.getAudioTracks()).called(1); + }); + + test('returns audio tracks with partial metadata', () async { + final ( + AndroidVideoPlayer player, + _, + MockVideoPlayerInstanceApi instanceApi, + ) = setUpMockPlayer(playerId: 1); + + final List mockTracks = [ + AudioTrackMessage( + id: 'track1', + label: 'Default', + language: 'und', + isSelected: true, + ), + AudioTrackMessage( + id: 'track2', + label: 'High Quality', + language: 'en', + isSelected: false, + bitrate: 256000, + sampleRate: 48000, + codec: 'aac', + ), + ]; + + when(instanceApi.getAudioTracks()).thenAnswer((_) async => mockTracks); + + final List tracks = await player.getAudioTracks(1); + + expect(tracks, hasLength(2)); + + expect(tracks[0].id, 'track1'); + expect(tracks[0].label, 'Default'); + expect(tracks[0].language, 'und'); + expect(tracks[0].isSelected, true); + expect(tracks[0].bitrate, null); + expect(tracks[0].sampleRate, null); + expect(tracks[0].channelCount, null); + expect(tracks[0].codec, null); + + expect(tracks[1].id, 'track2'); + expect(tracks[1].label, 'High Quality'); + expect(tracks[1].language, 'en'); + expect(tracks[1].isSelected, false); + expect(tracks[1].bitrate, 256000); + expect(tracks[1].sampleRate, 48000); + expect(tracks[1].channelCount, null); + expect(tracks[1].codec, 'aac'); + + verify(instanceApi.getAudioTracks()).called(1); + }); + + test('returns empty list when no audio tracks available', () async { + final ( + AndroidVideoPlayer player, + _, + MockVideoPlayerInstanceApi instanceApi, + ) = setUpMockPlayer(playerId: 1); + + when(instanceApi.getAudioTracks()).thenAnswer((_) async => []); + + final List tracks = await player.getAudioTracks(1); + + expect(tracks, isEmpty); + verify(instanceApi.getAudioTracks()).called(1); + }); + + test('handles different channel configurations', () async { + final ( + AndroidVideoPlayer player, + _, + MockVideoPlayerInstanceApi instanceApi, + ) = setUpMockPlayer(playerId: 1); + + final List mockTracks = [ + AudioTrackMessage( + id: 'mono', + label: 'Mono Track', + language: 'en', + isSelected: false, + bitrate: 64000, + sampleRate: 22050, + channelCount: 1, + codec: 'aac', + ), + AudioTrackMessage( + id: 'stereo', + label: 'Stereo Track', + language: 'en', + isSelected: true, + bitrate: 128000, + sampleRate: 44100, + channelCount: 2, + codec: 'aac', + ), + AudioTrackMessage( + id: 'surround', + label: '5.1 Surround', + language: 'en', + isSelected: false, + bitrate: 384000, + sampleRate: 48000, + channelCount: 6, + codec: 'ac3', + ), + AudioTrackMessage( + id: 'surround71', + label: '7.1 Surround', + language: 'en', + isSelected: false, + bitrate: 512000, + sampleRate: 48000, + channelCount: 8, + codec: 'eac3', + ), + ]; + + when(instanceApi.getAudioTracks()).thenAnswer((_) async => mockTracks); + + final List tracks = await player.getAudioTracks(1); + + expect(tracks, hasLength(4)); + expect(tracks[0].channelCount, 1); + expect(tracks[1].channelCount, 2); + expect(tracks[2].channelCount, 6); + expect(tracks[3].channelCount, 8); + + verify(instanceApi.getAudioTracks()).called(1); + }); + + test('handles different codec types', () async { + final ( + AndroidVideoPlayer player, + _, + MockVideoPlayerInstanceApi instanceApi, + ) = setUpMockPlayer(playerId: 1); + + final List mockTracks = [ + AudioTrackMessage( + id: 'aac_track', + label: 'AAC Track', + language: 'en', + isSelected: true, + bitrate: 128000, + sampleRate: 48000, + channelCount: 2, + codec: 'aac', + ), + AudioTrackMessage( + id: 'mp3_track', + label: 'MP3 Track', + language: 'en', + isSelected: false, + bitrate: 320000, + sampleRate: 44100, + channelCount: 2, + codec: 'mp3', + ), + AudioTrackMessage( + id: 'opus_track', + label: 'Opus Track', + language: 'en', + isSelected: false, + bitrate: 96000, + sampleRate: 48000, + channelCount: 2, + codec: 'opus', + ), + ]; + + when(instanceApi.getAudioTracks()).thenAnswer((_) async => mockTracks); + + final List tracks = await player.getAudioTracks(1); + + expect(tracks, hasLength(3)); + expect(tracks[0].codec, 'aac'); + expect(tracks[1].codec, 'mp3'); + expect(tracks[2].codec, 'opus'); + + verify(instanceApi.getAudioTracks()).called(1); + }); + + test('throws PlatformException when native method fails', () async { + final ( + AndroidVideoPlayer player, + _, + MockVideoPlayerInstanceApi instanceApi, + ) = setUpMockPlayer(playerId: 1); + + when(instanceApi.getAudioTracks()).thenThrow( + PlatformException( + code: 'AUDIO_TRACKS_ERROR', + message: 'Failed to retrieve audio tracks', + ), + ); + + expect( + () => player.getAudioTracks(1), + throwsA(isA()), + ); + + verify(instanceApi.getAudioTracks()).called(1); + }); + }); }); } 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 68be21719be..d4c7674be89 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 @@ -181,4 +181,18 @@ class MockVideoPlayerInstanceApi extends _i1.Mock returnValueForMissingStub: _i4.Future.value(), ) as _i4.Future); + + @override + _i4.Future> getAudioTracks() => + (super.noSuchMethod( + Invocation.method(#getAudioTracks, []), + returnValue: _i4.Future>.value( + <_i2.AudioTrackMessage>[], + ), + returnValueForMissingStub: + _i4.Future>.value( + <_i2.AudioTrackMessage>[], + ), + ) + as _i4.Future>); } diff --git a/packages/video_player/video_player_avfoundation/CHANGELOG.md b/packages/video_player/video_player_avfoundation/CHANGELOG.md index 982b1ae65fa..69ceb8943be 100644 --- a/packages/video_player/video_player_avfoundation/CHANGELOG.md +++ b/packages/video_player/video_player_avfoundation/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.9.0 + +* Adds audio track metadata support including bitrate, sample rate, channel count, and codec information. + ## 2.8.3 * Removes calls to self from init and dealloc, for maintainability. diff --git a/packages/video_player/video_player_avfoundation/darwin/RunnerTests/AudioTracksTests.m b/packages/video_player/video_player_avfoundation/darwin/RunnerTests/AudioTracksTests.m new file mode 100644 index 00000000000..e3bb66eab29 --- /dev/null +++ b/packages/video_player/video_player_avfoundation/darwin/RunnerTests/AudioTracksTests.m @@ -0,0 +1,272 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import +#import +#import + +#import "video_player_avfoundation/FVPVideoPlayer.h" +#import "video_player_avfoundation/messages.g.h" + +@interface AudioTracksTests : XCTestCase +@property(nonatomic, strong) FVPVideoPlayer *player; +@property(nonatomic, strong) id mockPlayer; +@property(nonatomic, strong) id mockPlayerItem; +@property(nonatomic, strong) id mockAsset; +@property(nonatomic, strong) id mockAVFactory; +@property(nonatomic, strong) id mockViewProvider; +@end + +@implementation AudioTracksTests + +- (void)setUp { + [super setUp]; + + // Create mocks + self.mockPlayer = OCMClassMock([AVPlayer class]); + self.mockPlayerItem = OCMClassMock([AVPlayerItem class]); + self.mockAsset = OCMClassMock([AVAsset class]); + self.mockAVFactory = OCMProtocolMock(@protocol(FVPAVFactory)); + self.mockViewProvider = OCMProtocolMock(@protocol(FVPViewProvider)); + + // Set up basic mock relationships + OCMStub([self.mockPlayer currentItem]).andReturn(self.mockPlayerItem); + OCMStub([self.mockPlayerItem asset]).andReturn(self.mockAsset); + OCMStub([self.mockAVFactory playerWithPlayerItem:OCMOCK_ANY]).andReturn(self.mockPlayer); + + // Create player with mocks + self.player = [[FVPVideoPlayer alloc] initWithPlayerItem:self.mockPlayerItem + avFactory:self.mockAVFactory + viewProvider:self.mockViewProvider]; +} + +- (void)tearDown { + [self.player dispose]; + self.player = nil; + [super tearDown]; +} + +#pragma mark - Asset Track Tests + +- (void)testGetAudioTracksWithRegularAssetTracks { + // Create mock asset tracks + id mockTrack1 = OCMClassMock([AVAssetTrack class]); + id mockTrack2 = OCMClassMock([AVAssetTrack class]); + + // Configure track 1 + OCMStub([mockTrack1 trackID]).andReturn(1); + OCMStub([mockTrack1 languageCode]).andReturn(@"en"); + OCMStub([mockTrack1 estimatedDataRate]).andReturn(128000.0f); + + // Configure track 2 + OCMStub([mockTrack2 trackID]).andReturn(2); + OCMStub([mockTrack2 languageCode]).andReturn(@"es"); + OCMStub([mockTrack2 estimatedDataRate]).andReturn(96000.0f); + + // Mock format descriptions for track 1 + id mockFormatDesc1 = OCMClassMock([NSObject class]); + AudioStreamBasicDescription asbd1 = {0}; + asbd1.mSampleRate = 48000.0; + asbd1.mChannelsPerFrame = 2; + + OCMStub([mockTrack1 formatDescriptions]).andReturn(@[mockFormatDesc1]); + + // Mock the asset to return our tracks + NSArray *mockTracks = @[mockTrack1, mockTrack2]; + OCMStub([self.mockAsset tracksWithMediaType:AVMediaTypeAudio]).andReturn(mockTracks); + + // Mock no media selection group (regular asset) + OCMStub([self.mockAsset mediaSelectionGroupForMediaCharacteristic:AVMediaCharacteristicAudible]).andReturn(nil); + + // Test the method + FlutterError *error = nil; + FVPNativeAudioTrackData *result = [self.player getAudioTracks:&error]; + + // Verify results + XCTAssertNil(error); + XCTAssertNotNil(result); + XCTAssertNotNil(result.assetTracks); + XCTAssertNil(result.mediaSelectionTracks); + XCTAssertEqual(result.assetTracks.count, 2); + + // Verify first track + FVPAssetAudioTrackData *track1 = result.assetTracks[0]; + XCTAssertEqualObjects(track1.trackId, @1); + XCTAssertEqualObjects(track1.language, @"en"); + XCTAssertTrue(track1.isSelected); // First track should be selected + XCTAssertEqualObjects(track1.bitrate, @128000); + + // Verify second track + FVPAssetAudioTrackData *track2 = result.assetTracks[1]; + XCTAssertEqualObjects(track2.trackId, @2); + XCTAssertEqualObjects(track2.language, @"es"); + XCTAssertFalse(track2.isSelected); // Second track should not be selected + XCTAssertEqualObjects(track2.bitrate, @96000); +} + +- (void)testGetAudioTracksWithMediaSelectionOptions { + // Create mock media selection group and options + id mockMediaSelectionGroup = OCMClassMock([AVMediaSelectionGroup class]); + id mockOption1 = OCMClassMock([AVMediaSelectionOption class]); + id mockOption2 = OCMClassMock([AVMediaSelectionOption class]); + + // Configure option 1 + OCMStub([mockOption1 displayName]).andReturn(@"English"); + id mockLocale1 = OCMClassMock([NSLocale class]); + OCMStub([mockLocale1 languageCode]).andReturn(@"en"); + OCMStub([mockOption1 locale]).andReturn(mockLocale1); + + // Configure option 2 + OCMStub([mockOption2 displayName]).andReturn(@"Español"); + id mockLocale2 = OCMClassMock([NSLocale class]); + OCMStub([mockLocale2 languageCode]).andReturn(@"es"); + OCMStub([mockOption2 locale]).andReturn(mockLocale2); + + // Mock metadata for option 1 + id mockMetadataItem = OCMClassMock([AVMetadataItem class]); + OCMStub([mockMetadataItem commonKey]).andReturn(AVMetadataCommonKeyTitle); + OCMStub([mockMetadataItem stringValue]).andReturn(@"English Audio Track"); + OCMStub([mockOption1 commonMetadata]).andReturn(@[mockMetadataItem]); + + // Configure media selection group + NSArray *options = @[mockOption1, mockOption2]; + OCMStub([mockMediaSelectionGroup options]).andReturn(options); + OCMStub([mockMediaSelectionGroup.options count]).andReturn(2); + + // Mock the asset to return media selection group + OCMStub([self.mockAsset mediaSelectionGroupForMediaCharacteristic:AVMediaCharacteristicAudible]).andReturn(mockMediaSelectionGroup); + + // Mock current selection + OCMStub([self.mockPlayerItem selectedMediaOptionInMediaSelectionGroup:mockMediaSelectionGroup]).andReturn(mockOption1); + + // Test the method + FlutterError *error = nil; + FVPNativeAudioTrackData *result = [self.player getAudioTracks:&error]; + + // Verify results + XCTAssertNil(error); + XCTAssertNotNil(result); + XCTAssertNil(result.assetTracks); + XCTAssertNotNil(result.mediaSelectionTracks); + XCTAssertEqual(result.mediaSelectionTracks.count, 2); + + // Verify first option + FVPMediaSelectionAudioTrackData *option1Data = result.mediaSelectionTracks[0]; + XCTAssertEqualObjects(option1Data.index, @0); + XCTAssertEqualObjects(option1Data.displayName, @"English"); + XCTAssertEqualObjects(option1Data.languageCode, @"en"); + XCTAssertTrue(option1Data.isSelected); + XCTAssertEqualObjects(option1Data.commonMetadataTitle, @"English Audio Track"); + + // Verify second option + FVPMediaSelectionAudioTrackData *option2Data = result.mediaSelectionTracks[1]; + XCTAssertEqualObjects(option2Data.index, @1); + XCTAssertEqualObjects(option2Data.displayName, @"Español"); + XCTAssertEqualObjects(option2Data.languageCode, @"es"); + XCTAssertFalse(option2Data.isSelected); +} + +- (void)testGetAudioTracksWithNoCurrentItem { + // Mock player with no current item + OCMStub([self.mockPlayer currentItem]).andReturn(nil); + + // Test the method + FlutterError *error = nil; + FVPNativeAudioTrackData *result = [self.player getAudioTracks:&error]; + + // Verify results + XCTAssertNil(error); + XCTAssertNotNil(result); + XCTAssertNil(result.assetTracks); + XCTAssertNil(result.mediaSelectionTracks); +} + +- (void)testGetAudioTracksWithNoAsset { + // Mock player item with no asset + OCMStub([self.mockPlayerItem asset]).andReturn(nil); + + // Test the method + FlutterError *error = nil; + FVPNativeAudioTrackData *result = [self.player getAudioTracks:&error]; + + // Verify results + XCTAssertNil(error); + XCTAssertNotNil(result); + XCTAssertNil(result.assetTracks); + XCTAssertNil(result.mediaSelectionTracks); +} + +- (void)testGetAudioTracksCodecDetection { + // Create mock asset track with format description + id mockTrack = OCMClassMock([AVAssetTrack class]); + OCMStub([mockTrack trackID]).andReturn(1); + OCMStub([mockTrack languageCode]).andReturn(@"en"); + + // Mock format description with AAC codec + id mockFormatDesc = OCMClassMock([NSObject class]); + OCMStub([mockTrack formatDescriptions]).andReturn(@[mockFormatDesc]); + + // Mock the asset + OCMStub([self.mockAsset tracksWithMediaType:AVMediaTypeAudio]).andReturn(@[mockTrack]); + OCMStub([self.mockAsset mediaSelectionGroupForMediaCharacteristic:AVMediaCharacteristicAudible]).andReturn(nil); + + // Test the method + FlutterError *error = nil; + FVPNativeAudioTrackData *result = [self.player getAudioTracks:&error]; + + // Verify results + XCTAssertNil(error); + XCTAssertNotNil(result); + XCTAssertNotNil(result.assetTracks); + XCTAssertEqual(result.assetTracks.count, 1); + + FVPAssetAudioTrackData *track = result.assetTracks[0]; + XCTAssertEqualObjects(track.trackId, @1); + XCTAssertEqualObjects(track.language, @"en"); +} + +- (void)testGetAudioTracksWithEmptyMediaSelectionOptions { + // Create mock media selection group with no options + id mockMediaSelectionGroup = OCMClassMock([AVMediaSelectionGroup class]); + OCMStub([mockMediaSelectionGroup options]).andReturn(@[]); + OCMStub([mockMediaSelectionGroup.options count]).andReturn(0); + + // Mock the asset + OCMStub([self.mockAsset mediaSelectionGroupForMediaCharacteristic:AVMediaCharacteristicAudible]).andReturn(mockMediaSelectionGroup); + OCMStub([self.mockAsset tracksWithMediaType:AVMediaTypeAudio]).andReturn(@[]); + + // Test the method + FlutterError *error = nil; + FVPNativeAudioTrackData *result = [self.player getAudioTracks:&error]; + + // Verify results - should fall back to asset tracks + XCTAssertNil(error); + XCTAssertNotNil(result); + XCTAssertNotNil(result.assetTracks); + XCTAssertNil(result.mediaSelectionTracks); + XCTAssertEqual(result.assetTracks.count, 0); +} + +- (void)testGetAudioTracksWithNilMediaSelectionOption { + // Create mock media selection group with nil option + id mockMediaSelectionGroup = OCMClassMock([AVMediaSelectionGroup class]); + NSArray *options = @[[NSNull null]]; // Simulate nil option + OCMStub([mockMediaSelectionGroup options]).andReturn(options); + OCMStub([mockMediaSelectionGroup.options count]).andReturn(1); + + // Mock the asset + OCMStub([self.mockAsset mediaSelectionGroupForMediaCharacteristic:AVMediaCharacteristicAudible]).andReturn(mockMediaSelectionGroup); + + // Test the method + FlutterError *error = nil; + FVPNativeAudioTrackData *result = [self.player getAudioTracks:&error]; + + // Verify results - should handle nil option gracefully + XCTAssertNil(error); + XCTAssertNotNil(result); + XCTAssertNotNil(result.mediaSelectionTracks); + XCTAssertEqual(result.mediaSelectionTracks.count, 0); // Should skip nil options +} + +@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 4667441cb97..bc3e3cf8fbd 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 @@ -479,6 +479,8 @@ - (void)setPlaybackSpeed:(double)speed error:(FlutterError *_Nullable *_Nonnull) [self updatePlayingState]; } + + #pragma mark - Private - (int64_t)duration { @@ -488,4 +490,125 @@ - (int64_t)duration { return FVPCMTimeToMillis([[[_player currentItem] asset] duration]); } +- (nullable FVPNativeAudioTrackData *)getAudioTracks:(FlutterError *_Nullable *_Nonnull)error { + AVPlayerItem *currentItem = _player.currentItem; + if (!currentItem || !currentItem.asset) { + return [FVPNativeAudioTrackData makeWithAssetTracks:nil mediaSelectionTracks:nil]; + } + + AVAsset *asset = currentItem.asset; + + // Check for media selection options (HLS streams) + AVMediaSelectionGroup *audioGroup = [asset mediaSelectionGroupForMediaCharacteristic:AVMediaCharacteristicAudible]; + if (audioGroup && audioGroup.options.count > 0) { + // Return media selection track data for HLS streams + NSMutableArray *mediaSelectionTracks = [[NSMutableArray alloc] init]; + AVMediaSelectionOption *currentSelection = [currentItem selectedMediaOptionInMediaSelectionGroup:audioGroup]; + + NSInteger trackIndex = 0; + for (NSInteger i = 0; i < audioGroup.options.count; i++) { + AVMediaSelectionOption *option = audioGroup.options[i]; + if (!option) continue; + + BOOL isSelected = (currentSelection == option); + + // Extract metadata title + NSString *commonMetadataTitle = nil; + if (option.commonMetadata) { + for (AVMetadataItem *item in option.commonMetadata) { + if ([item.commonKey isEqualToString:AVMetadataCommonKeyTitle]) { + commonMetadataTitle = [item stringValue]; + break; + } + } + } + + FVPMediaSelectionAudioTrackData *trackData = [FVPMediaSelectionAudioTrackData + makeWithIndex:trackIndex + displayName:option.displayName + languageCode:option.locale ? option.locale.languageCode : nil + isSelected:isSelected + commonMetadataTitle:commonMetadataTitle]; + + [mediaSelectionTracks addObject:trackData]; + trackIndex++; + } + + return [FVPNativeAudioTrackData makeWithAssetTracks:nil mediaSelectionTracks:mediaSelectionTracks]; + } + + // Return asset track data for regular video files + NSArray *assetAudioTracks = [asset tracksWithMediaType:AVMediaTypeAudio]; + NSMutableArray *assetTracks = [[NSMutableArray alloc] init]; + + NSInteger trackIndex = 0; + for (NSInteger i = 0; i < assetAudioTracks.count; i++) { + AVAssetTrack *assetTrack = assetAudioTracks[i]; + + // Extract metadata from format descriptions + NSNumber *bitrate = nil; + NSNumber *sampleRate = nil; + NSNumber *channelCount = nil; + NSString *codec = nil; + + if (assetTrack.formatDescriptions.count > 0) { + CMFormatDescriptionRef formatDesc = (__bridge CMFormatDescriptionRef)assetTrack.formatDescriptions[0]; + if (formatDesc) { + const AudioStreamBasicDescription *asbd = CMAudioFormatDescriptionGetStreamBasicDescription(formatDesc); + if (asbd) { + sampleRate = @((NSInteger)asbd->mSampleRate); + channelCount = @((NSInteger)asbd->mChannelsPerFrame); + } + + FourCharCode codecType = CMFormatDescriptionGetMediaSubType(formatDesc); + switch (codecType) { + case kAudioFormatMPEG4AAC: + codec = @"aac"; + break; + case kAudioFormatMPEGLayer3: + codec = @"mp3"; + break; + case kAudioFormatAppleLossless: + codec = @"alac"; + break; + default: + codec = @"unknown"; + break; + } + + if (assetTrack.estimatedDataRate > 0) { + bitrate = @((NSInteger)assetTrack.estimatedDataRate); + } + } + } + + // Get label from metadata + NSString *label = nil; + for (AVMetadataItem *item in assetTrack.commonMetadata) { + if ([item.commonKey isEqualToString:AVMetadataCommonKeyTitle]) { + label = item.stringValue; + break; + } + } + + // Check if track is selected (for regular assets, usually the first track is selected) + BOOL isSelected = (i == 0); + + FVPAssetAudioTrackData *trackData = [FVPAssetAudioTrackData + makeWithTrackId:trackIndex + label:label + language:assetTrack.languageCode + isSelected:isSelected + bitrate:bitrate + sampleRate:sampleRate + channelCount:channelCount + codec:codec]; + + [assetTracks addObject:trackData]; + trackIndex++; + } + + return [FVPNativeAudioTrackData makeWithAssetTracks:assetTracks mediaSelectionTracks:nil]; +} + @end 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 b895043931e..b091764c0ff 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 @@ -27,48 +27,116 @@ typedef NS_ENUM(NSUInteger, FVPPlatformVideoViewType) { @class FVPPlatformVideoViewCreationParams; @class FVPCreationOptions; +@class FVPAudioTrackMessage; +@class FVPAssetAudioTrackData; +@class FVPMediaSelectionAudioTrackData; +@class FVPNativeAudioTrackData; /// Information passed to the platform view creation. @interface FVPPlatformVideoViewCreationParams : NSObject /// `init` unavailable to enforce nonnull fields, see the `make` class method. - (instancetype)init NS_UNAVAILABLE; -+ (instancetype)makeWithPlayerId:(NSInteger)playerId; -@property(nonatomic, assign) NSInteger playerId; ++ (instancetype)makeWithPlayerId:(NSInteger )playerId; +@property(nonatomic, assign) NSInteger playerId; @end @interface FVPCreationOptions : NSObject /// `init` unavailable to enforce nonnull fields, see the `make` class method. - (instancetype)init NS_UNAVAILABLE; + (instancetype)makeWithUri:(NSString *)uri - httpHeaders:(NSDictionary *)httpHeaders - viewType:(FVPPlatformVideoViewType)viewType; -@property(nonatomic, copy) NSString *uri; -@property(nonatomic, copy) NSDictionary *httpHeaders; + httpHeaders:(NSDictionary *)httpHeaders + viewType:(FVPPlatformVideoViewType)viewType; +@property(nonatomic, copy) NSString * uri; +@property(nonatomic, copy) NSDictionary * httpHeaders; @property(nonatomic, assign) FVPPlatformVideoViewType viewType; @end +/// Represents an audio track in a video. +@interface FVPAudioTrackMessage : NSObject +/// `init` unavailable to enforce nonnull fields, see the `make` class method. +- (instancetype)init NS_UNAVAILABLE; ++ (instancetype)makeWithId:(NSString *)id + label:(NSString *)label + language:(NSString *)language + isSelected:(BOOL )isSelected + bitrate:(nullable NSNumber *)bitrate + sampleRate:(nullable NSNumber *)sampleRate + channelCount:(nullable NSNumber *)channelCount + codec:(nullable NSString *)codec; +@property(nonatomic, copy) NSString * id; +@property(nonatomic, copy) NSString * label; +@property(nonatomic, copy) NSString * language; +@property(nonatomic, assign) BOOL isSelected; +@property(nonatomic, strong, nullable) NSNumber * bitrate; +@property(nonatomic, strong, nullable) NSNumber * sampleRate; +@property(nonatomic, strong, nullable) NSNumber * channelCount; +@property(nonatomic, copy, nullable) NSString * codec; +@end + +/// Raw audio track data from AVAssetTrack (for regular assets). +@interface FVPAssetAudioTrackData : NSObject +/// `init` unavailable to enforce nonnull fields, see the `make` class method. +- (instancetype)init NS_UNAVAILABLE; ++ (instancetype)makeWithTrackId:(NSInteger )trackId + label:(nullable NSString *)label + language:(nullable NSString *)language + isSelected:(BOOL )isSelected + bitrate:(nullable NSNumber *)bitrate + sampleRate:(nullable NSNumber *)sampleRate + channelCount:(nullable NSNumber *)channelCount + codec:(nullable NSString *)codec; +@property(nonatomic, assign) NSInteger trackId; +@property(nonatomic, copy, nullable) NSString * label; +@property(nonatomic, copy, nullable) NSString * language; +@property(nonatomic, assign) BOOL isSelected; +@property(nonatomic, strong, nullable) NSNumber * bitrate; +@property(nonatomic, strong, nullable) NSNumber * sampleRate; +@property(nonatomic, strong, nullable) NSNumber * channelCount; +@property(nonatomic, copy, nullable) NSString * codec; +@end + +/// Raw audio track data from AVMediaSelectionOption (for HLS streams). +@interface FVPMediaSelectionAudioTrackData : NSObject +/// `init` unavailable to enforce nonnull fields, see the `make` class method. +- (instancetype)init NS_UNAVAILABLE; ++ (instancetype)makeWithIndex:(NSInteger )index + displayName:(nullable NSString *)displayName + languageCode:(nullable NSString *)languageCode + isSelected:(BOOL )isSelected + commonMetadataTitle:(nullable NSString *)commonMetadataTitle; +@property(nonatomic, assign) NSInteger index; +@property(nonatomic, copy, nullable) NSString * displayName; +@property(nonatomic, copy, nullable) NSString * languageCode; +@property(nonatomic, assign) BOOL isSelected; +@property(nonatomic, copy, nullable) NSString * commonMetadataTitle; +@end + +/// Container for raw audio track data from native platforms. +@interface FVPNativeAudioTrackData : NSObject ++ (instancetype)makeWithAssetTracks:(nullable NSArray *)assetTracks + mediaSelectionTracks:(nullable NSArray *)mediaSelectionTracks; +/// Asset-based tracks (for regular video files) +@property(nonatomic, copy, nullable) NSArray * assetTracks; +/// Media selection-based tracks (for HLS streams) +@property(nonatomic, copy, nullable) NSArray * mediaSelectionTracks; +@end + /// The codec used by all APIs. NSObject *FVPGetMessagesCodec(void); @protocol FVPAVFoundationVideoPlayerApi - (void)initialize:(FlutterError *_Nullable *_Nonnull)error; /// @return `nil` only when `error != nil`. -- (nullable NSNumber *)createWithOptions:(FVPCreationOptions *)creationOptions - error:(FlutterError *_Nullable *_Nonnull)error; +- (nullable NSNumber *)createWithOptions:(FVPCreationOptions *)creationOptions error:(FlutterError *_Nullable *_Nonnull)error; - (void)disposePlayer:(NSInteger)playerId error:(FlutterError *_Nullable *_Nonnull)error; - (void)setMixWithOthers:(BOOL)mixWithOthers error:(FlutterError *_Nullable *_Nonnull)error; -- (nullable NSString *)fileURLForAssetWithName:(NSString *)asset - package:(nullable NSString *)package - error:(FlutterError *_Nullable *_Nonnull)error; +- (nullable NSString *)fileURLForAssetWithName:(NSString *)asset package:(nullable NSString *)package error:(FlutterError *_Nullable *_Nonnull)error; @end -extern void SetUpFVPAVFoundationVideoPlayerApi( - id binaryMessenger, - NSObject *_Nullable api); +extern void SetUpFVPAVFoundationVideoPlayerApi(id binaryMessenger, NSObject *_Nullable api); + +extern void SetUpFVPAVFoundationVideoPlayerApiWithSuffix(id binaryMessenger, NSObject *_Nullable api, NSString *messageChannelSuffix); -extern void SetUpFVPAVFoundationVideoPlayerApiWithSuffix( - id binaryMessenger, - NSObject *_Nullable api, NSString *messageChannelSuffix); @protocol FVPVideoPlayerInstanceApi - (void)setLooping:(BOOL)looping error:(FlutterError *_Nullable *_Nonnull)error; @@ -79,13 +147,12 @@ extern void SetUpFVPAVFoundationVideoPlayerApiWithSuffix( - (nullable NSNumber *)position:(FlutterError *_Nullable *_Nonnull)error; - (void)seekTo:(NSInteger)position completion:(void (^)(FlutterError *_Nullable))completion; - (void)pauseWithError:(FlutterError *_Nullable *_Nonnull)error; +/// @return `nil` only when `error != nil`. +- (nullable FVPNativeAudioTrackData *)getAudioTracks:(FlutterError *_Nullable *_Nonnull)error; @end -extern void SetUpFVPVideoPlayerInstanceApi(id binaryMessenger, - NSObject *_Nullable api); +extern void SetUpFVPVideoPlayerInstanceApi(id binaryMessenger, NSObject *_Nullable api); -extern void SetUpFVPVideoPlayerInstanceApiWithSuffix( - id binaryMessenger, NSObject *_Nullable api, - NSString *messageChannelSuffix); +extern void SetUpFVPVideoPlayerInstanceApiWithSuffix(id binaryMessenger, NSObject *_Nullable api, NSString *messageChannelSuffix); NS_ASSUME_NONNULL_END 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 13076be0e91..a4574db9136 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 @@ -53,16 +53,38 @@ + (nullable FVPCreationOptions *)nullableFromList:(NSArray *)list; - (NSArray *)toList; @end +@interface FVPAudioTrackMessage () ++ (FVPAudioTrackMessage *)fromList:(NSArray *)list; ++ (nullable FVPAudioTrackMessage *)nullableFromList:(NSArray *)list; +- (NSArray *)toList; +@end + +@interface FVPAssetAudioTrackData () ++ (FVPAssetAudioTrackData *)fromList:(NSArray *)list; ++ (nullable FVPAssetAudioTrackData *)nullableFromList:(NSArray *)list; +- (NSArray *)toList; +@end + +@interface FVPMediaSelectionAudioTrackData () ++ (FVPMediaSelectionAudioTrackData *)fromList:(NSArray *)list; ++ (nullable FVPMediaSelectionAudioTrackData *)nullableFromList:(NSArray *)list; +- (NSArray *)toList; +@end + +@interface FVPNativeAudioTrackData () ++ (FVPNativeAudioTrackData *)fromList:(NSArray *)list; ++ (nullable FVPNativeAudioTrackData *)nullableFromList:(NSArray *)list; +- (NSArray *)toList; +@end + @implementation FVPPlatformVideoViewCreationParams -+ (instancetype)makeWithPlayerId:(NSInteger)playerId { - FVPPlatformVideoViewCreationParams *pigeonResult = - [[FVPPlatformVideoViewCreationParams alloc] init]; ++ (instancetype)makeWithPlayerId:(NSInteger )playerId { + FVPPlatformVideoViewCreationParams* pigeonResult = [[FVPPlatformVideoViewCreationParams alloc] init]; pigeonResult.playerId = playerId; return pigeonResult; } + (FVPPlatformVideoViewCreationParams *)fromList:(NSArray *)list { - FVPPlatformVideoViewCreationParams *pigeonResult = - [[FVPPlatformVideoViewCreationParams alloc] init]; + FVPPlatformVideoViewCreationParams *pigeonResult = [[FVPPlatformVideoViewCreationParams alloc] init]; pigeonResult.playerId = [GetNullableObjectAtIndex(list, 0) integerValue]; return pigeonResult; } @@ -78,9 +100,9 @@ + (nullable FVPPlatformVideoViewCreationParams *)nullableFromList:(NSArray * @implementation FVPCreationOptions + (instancetype)makeWithUri:(NSString *)uri - httpHeaders:(NSDictionary *)httpHeaders - viewType:(FVPPlatformVideoViewType)viewType { - FVPCreationOptions *pigeonResult = [[FVPCreationOptions alloc] init]; + httpHeaders:(NSDictionary *)httpHeaders + viewType:(FVPPlatformVideoViewType)viewType { + FVPCreationOptions* pigeonResult = [[FVPCreationOptions alloc] init]; pigeonResult.uri = uri; pigeonResult.httpHeaders = httpHeaders; pigeonResult.viewType = viewType; @@ -106,6 +128,166 @@ + (nullable FVPCreationOptions *)nullableFromList:(NSArray *)list { } @end +@implementation FVPAudioTrackMessage ++ (instancetype)makeWithId:(NSString *)id + label:(NSString *)label + language:(NSString *)language + isSelected:(BOOL )isSelected + bitrate:(nullable NSNumber *)bitrate + sampleRate:(nullable NSNumber *)sampleRate + channelCount:(nullable NSNumber *)channelCount + codec:(nullable NSString *)codec { + FVPAudioTrackMessage* pigeonResult = [[FVPAudioTrackMessage alloc] init]; + pigeonResult.id = id; + pigeonResult.label = label; + pigeonResult.language = language; + pigeonResult.isSelected = isSelected; + pigeonResult.bitrate = bitrate; + pigeonResult.sampleRate = sampleRate; + pigeonResult.channelCount = channelCount; + pigeonResult.codec = codec; + return pigeonResult; +} ++ (FVPAudioTrackMessage *)fromList:(NSArray *)list { + FVPAudioTrackMessage *pigeonResult = [[FVPAudioTrackMessage alloc] init]; + pigeonResult.id = GetNullableObjectAtIndex(list, 0); + pigeonResult.label = GetNullableObjectAtIndex(list, 1); + pigeonResult.language = GetNullableObjectAtIndex(list, 2); + pigeonResult.isSelected = [GetNullableObjectAtIndex(list, 3) boolValue]; + pigeonResult.bitrate = GetNullableObjectAtIndex(list, 4); + pigeonResult.sampleRate = GetNullableObjectAtIndex(list, 5); + pigeonResult.channelCount = GetNullableObjectAtIndex(list, 6); + pigeonResult.codec = GetNullableObjectAtIndex(list, 7); + return pigeonResult; +} ++ (nullable FVPAudioTrackMessage *)nullableFromList:(NSArray *)list { + return (list) ? [FVPAudioTrackMessage fromList:list] : nil; +} +- (NSArray *)toList { + return @[ + self.id ?: [NSNull null], + self.label ?: [NSNull null], + self.language ?: [NSNull null], + @(self.isSelected), + self.bitrate ?: [NSNull null], + self.sampleRate ?: [NSNull null], + self.channelCount ?: [NSNull null], + self.codec ?: [NSNull null], + ]; +} +@end + +@implementation FVPAssetAudioTrackData ++ (instancetype)makeWithTrackId:(NSInteger )trackId + label:(nullable NSString *)label + language:(nullable NSString *)language + isSelected:(BOOL )isSelected + bitrate:(nullable NSNumber *)bitrate + sampleRate:(nullable NSNumber *)sampleRate + channelCount:(nullable NSNumber *)channelCount + codec:(nullable NSString *)codec { + FVPAssetAudioTrackData* pigeonResult = [[FVPAssetAudioTrackData alloc] init]; + pigeonResult.trackId = trackId; + pigeonResult.label = label; + pigeonResult.language = language; + pigeonResult.isSelected = isSelected; + pigeonResult.bitrate = bitrate; + pigeonResult.sampleRate = sampleRate; + pigeonResult.channelCount = channelCount; + pigeonResult.codec = codec; + return pigeonResult; +} ++ (FVPAssetAudioTrackData *)fromList:(NSArray *)list { + FVPAssetAudioTrackData *pigeonResult = [[FVPAssetAudioTrackData alloc] init]; + pigeonResult.trackId = [GetNullableObjectAtIndex(list, 0) integerValue]; + pigeonResult.label = GetNullableObjectAtIndex(list, 1); + pigeonResult.language = GetNullableObjectAtIndex(list, 2); + pigeonResult.isSelected = [GetNullableObjectAtIndex(list, 3) boolValue]; + pigeonResult.bitrate = GetNullableObjectAtIndex(list, 4); + pigeonResult.sampleRate = GetNullableObjectAtIndex(list, 5); + pigeonResult.channelCount = GetNullableObjectAtIndex(list, 6); + pigeonResult.codec = GetNullableObjectAtIndex(list, 7); + return pigeonResult; +} ++ (nullable FVPAssetAudioTrackData *)nullableFromList:(NSArray *)list { + return (list) ? [FVPAssetAudioTrackData fromList:list] : nil; +} +- (NSArray *)toList { + return @[ + @(self.trackId), + self.label ?: [NSNull null], + self.language ?: [NSNull null], + @(self.isSelected), + self.bitrate ?: [NSNull null], + self.sampleRate ?: [NSNull null], + self.channelCount ?: [NSNull null], + self.codec ?: [NSNull null], + ]; +} +@end + +@implementation FVPMediaSelectionAudioTrackData ++ (instancetype)makeWithIndex:(NSInteger )index + displayName:(nullable NSString *)displayName + languageCode:(nullable NSString *)languageCode + isSelected:(BOOL )isSelected + commonMetadataTitle:(nullable NSString *)commonMetadataTitle { + FVPMediaSelectionAudioTrackData* pigeonResult = [[FVPMediaSelectionAudioTrackData alloc] init]; + pigeonResult.index = index; + pigeonResult.displayName = displayName; + pigeonResult.languageCode = languageCode; + pigeonResult.isSelected = isSelected; + pigeonResult.commonMetadataTitle = commonMetadataTitle; + return pigeonResult; +} ++ (FVPMediaSelectionAudioTrackData *)fromList:(NSArray *)list { + FVPMediaSelectionAudioTrackData *pigeonResult = [[FVPMediaSelectionAudioTrackData alloc] init]; + pigeonResult.index = [GetNullableObjectAtIndex(list, 0) integerValue]; + pigeonResult.displayName = GetNullableObjectAtIndex(list, 1); + pigeonResult.languageCode = GetNullableObjectAtIndex(list, 2); + pigeonResult.isSelected = [GetNullableObjectAtIndex(list, 3) boolValue]; + pigeonResult.commonMetadataTitle = GetNullableObjectAtIndex(list, 4); + return pigeonResult; +} ++ (nullable FVPMediaSelectionAudioTrackData *)nullableFromList:(NSArray *)list { + return (list) ? [FVPMediaSelectionAudioTrackData fromList:list] : nil; +} +- (NSArray *)toList { + return @[ + @(self.index), + self.displayName ?: [NSNull null], + self.languageCode ?: [NSNull null], + @(self.isSelected), + self.commonMetadataTitle ?: [NSNull null], + ]; +} +@end + +@implementation FVPNativeAudioTrackData ++ (instancetype)makeWithAssetTracks:(nullable NSArray *)assetTracks + mediaSelectionTracks:(nullable NSArray *)mediaSelectionTracks { + FVPNativeAudioTrackData* pigeonResult = [[FVPNativeAudioTrackData alloc] init]; + pigeonResult.assetTracks = assetTracks; + pigeonResult.mediaSelectionTracks = mediaSelectionTracks; + return pigeonResult; +} ++ (FVPNativeAudioTrackData *)fromList:(NSArray *)list { + FVPNativeAudioTrackData *pigeonResult = [[FVPNativeAudioTrackData alloc] init]; + pigeonResult.assetTracks = GetNullableObjectAtIndex(list, 0); + pigeonResult.mediaSelectionTracks = GetNullableObjectAtIndex(list, 1); + return pigeonResult; +} ++ (nullable FVPNativeAudioTrackData *)nullableFromList:(NSArray *)list { + return (list) ? [FVPNativeAudioTrackData fromList:list] : nil; +} +- (NSArray *)toList { + return @[ + self.assetTracks ?: [NSNull null], + self.mediaSelectionTracks ?: [NSNull null], + ]; +} +@end + @interface FVPMessagesPigeonCodecReader : FlutterStandardReader @end @implementation FVPMessagesPigeonCodecReader @@ -113,14 +295,20 @@ - (nullable id)readValueOfType:(UInt8)type { switch (type) { case 129: { NSNumber *enumAsNumber = [self readValue]; - return enumAsNumber == nil - ? nil - : [[FVPPlatformVideoViewTypeBox alloc] initWithValue:[enumAsNumber integerValue]]; + return enumAsNumber == nil ? nil : [[FVPPlatformVideoViewTypeBox alloc] initWithValue:[enumAsNumber integerValue]]; } - case 130: + case 130: return [FVPPlatformVideoViewCreationParams fromList:[self readValue]]; - case 131: + case 131: return [FVPCreationOptions fromList:[self readValue]]; + case 132: + return [FVPAudioTrackMessage fromList:[self readValue]]; + case 133: + return [FVPAssetAudioTrackData fromList:[self readValue]]; + case 134: + return [FVPMediaSelectionAudioTrackData fromList:[self readValue]]; + case 135: + return [FVPNativeAudioTrackData fromList:[self readValue]]; default: return [super readValueOfType:type]; } @@ -141,6 +329,18 @@ - (void)writeValue:(id)value { } else if ([value isKindOfClass:[FVPCreationOptions class]]) { [self writeByte:131]; [self writeValue:[value toList]]; + } else if ([value isKindOfClass:[FVPAudioTrackMessage class]]) { + [self writeByte:132]; + [self writeValue:[value toList]]; + } else if ([value isKindOfClass:[FVPAssetAudioTrackData class]]) { + [self writeByte:133]; + [self writeValue:[value toList]]; + } else if ([value isKindOfClass:[FVPMediaSelectionAudioTrackData class]]) { + [self writeByte:134]; + [self writeValue:[value toList]]; + } else if ([value isKindOfClass:[FVPNativeAudioTrackData class]]) { + [self writeByte:135]; + [self writeValue:[value toList]]; } else { [super writeValue:value]; } @@ -162,35 +362,25 @@ - (FlutterStandardReader *)readerWithData:(NSData *)data { static FlutterStandardMessageCodec *sSharedObject = nil; static dispatch_once_t sPred = 0; dispatch_once(&sPred, ^{ - FVPMessagesPigeonCodecReaderWriter *readerWriter = - [[FVPMessagesPigeonCodecReaderWriter alloc] init]; + FVPMessagesPigeonCodecReaderWriter *readerWriter = [[FVPMessagesPigeonCodecReaderWriter alloc] init]; sSharedObject = [FlutterStandardMessageCodec codecWithReaderWriter:readerWriter]; }); return sSharedObject; } -void SetUpFVPAVFoundationVideoPlayerApi(id binaryMessenger, - NSObject *api) { +void SetUpFVPAVFoundationVideoPlayerApi(id binaryMessenger, NSObject *api) { SetUpFVPAVFoundationVideoPlayerApiWithSuffix(binaryMessenger, api, @""); } -void SetUpFVPAVFoundationVideoPlayerApiWithSuffix(id binaryMessenger, - NSObject *api, - NSString *messageChannelSuffix) { - messageChannelSuffix = messageChannelSuffix.length > 0 - ? [NSString stringWithFormat:@".%@", messageChannelSuffix] - : @""; +void SetUpFVPAVFoundationVideoPlayerApiWithSuffix(id binaryMessenger, NSObject *api, NSString *messageChannelSuffix) { + messageChannelSuffix = messageChannelSuffix.length > 0 ? [NSString stringWithFormat: @".%@", messageChannelSuffix] : @""; { - FlutterBasicMessageChannel *channel = [[FlutterBasicMessageChannel alloc] - initWithName:[NSString stringWithFormat:@"%@%@", - @"dev.flutter.pigeon.video_player_avfoundation." - @"AVFoundationVideoPlayerApi.initialize", - messageChannelSuffix] + FlutterBasicMessageChannel *channel = + [[FlutterBasicMessageChannel alloc] + initWithName:[NSString stringWithFormat:@"%@%@", @"dev.flutter.pigeon.video_player_avfoundation.AVFoundationVideoPlayerApi.initialize", messageChannelSuffix] binaryMessenger:binaryMessenger - codec:FVPGetMessagesCodec()]; + codec:FVPGetMessagesCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector(initialize:)], - @"FVPAVFoundationVideoPlayerApi api (%@) doesn't respond to @selector(initialize:)", - api); + NSCAssert([api respondsToSelector:@selector(initialize:)], @"FVPAVFoundationVideoPlayerApi api (%@) doesn't respond to @selector(initialize:)", api); [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { FlutterError *error; [api initialize:&error]; @@ -201,18 +391,13 @@ void SetUpFVPAVFoundationVideoPlayerApiWithSuffix(id bin } } { - FlutterBasicMessageChannel *channel = [[FlutterBasicMessageChannel alloc] - initWithName:[NSString stringWithFormat:@"%@%@", - @"dev.flutter.pigeon.video_player_avfoundation." - @"AVFoundationVideoPlayerApi.create", - messageChannelSuffix] + FlutterBasicMessageChannel *channel = + [[FlutterBasicMessageChannel alloc] + initWithName:[NSString stringWithFormat:@"%@%@", @"dev.flutter.pigeon.video_player_avfoundation.AVFoundationVideoPlayerApi.create", messageChannelSuffix] binaryMessenger:binaryMessenger - codec:FVPGetMessagesCodec()]; + codec:FVPGetMessagesCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector(createWithOptions:error:)], - @"FVPAVFoundationVideoPlayerApi api (%@) doesn't respond to " - @"@selector(createWithOptions:error:)", - api); + NSCAssert([api respondsToSelector:@selector(createWithOptions:error:)], @"FVPAVFoundationVideoPlayerApi api (%@) doesn't respond to @selector(createWithOptions:error:)", api); [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { NSArray *args = message; FVPCreationOptions *arg_creationOptions = GetNullableObjectAtIndex(args, 0); @@ -225,18 +410,13 @@ void SetUpFVPAVFoundationVideoPlayerApiWithSuffix(id bin } } { - FlutterBasicMessageChannel *channel = [[FlutterBasicMessageChannel alloc] - initWithName:[NSString stringWithFormat:@"%@%@", - @"dev.flutter.pigeon.video_player_avfoundation." - @"AVFoundationVideoPlayerApi.dispose", - messageChannelSuffix] + FlutterBasicMessageChannel *channel = + [[FlutterBasicMessageChannel alloc] + initWithName:[NSString stringWithFormat:@"%@%@", @"dev.flutter.pigeon.video_player_avfoundation.AVFoundationVideoPlayerApi.dispose", messageChannelSuffix] binaryMessenger:binaryMessenger - codec:FVPGetMessagesCodec()]; + codec:FVPGetMessagesCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector(disposePlayer:error:)], - @"FVPAVFoundationVideoPlayerApi api (%@) doesn't respond to " - @"@selector(disposePlayer:error:)", - api); + NSCAssert([api respondsToSelector:@selector(disposePlayer:error:)], @"FVPAVFoundationVideoPlayerApi api (%@) doesn't respond to @selector(disposePlayer:error:)", api); [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { NSArray *args = message; NSInteger arg_playerId = [GetNullableObjectAtIndex(args, 0) integerValue]; @@ -249,18 +429,13 @@ void SetUpFVPAVFoundationVideoPlayerApiWithSuffix(id bin } } { - FlutterBasicMessageChannel *channel = [[FlutterBasicMessageChannel alloc] - initWithName:[NSString stringWithFormat:@"%@%@", - @"dev.flutter.pigeon.video_player_avfoundation." - @"AVFoundationVideoPlayerApi.setMixWithOthers", - messageChannelSuffix] + FlutterBasicMessageChannel *channel = + [[FlutterBasicMessageChannel alloc] + initWithName:[NSString stringWithFormat:@"%@%@", @"dev.flutter.pigeon.video_player_avfoundation.AVFoundationVideoPlayerApi.setMixWithOthers", messageChannelSuffix] binaryMessenger:binaryMessenger - codec:FVPGetMessagesCodec()]; + codec:FVPGetMessagesCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector(setMixWithOthers:error:)], - @"FVPAVFoundationVideoPlayerApi api (%@) doesn't respond to " - @"@selector(setMixWithOthers:error:)", - api); + NSCAssert([api respondsToSelector:@selector(setMixWithOthers:error:)], @"FVPAVFoundationVideoPlayerApi api (%@) doesn't respond to @selector(setMixWithOthers:error:)", api); [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { NSArray *args = message; BOOL arg_mixWithOthers = [GetNullableObjectAtIndex(args, 0) boolValue]; @@ -273,18 +448,13 @@ void SetUpFVPAVFoundationVideoPlayerApiWithSuffix(id bin } } { - FlutterBasicMessageChannel *channel = [[FlutterBasicMessageChannel alloc] - initWithName:[NSString stringWithFormat:@"%@%@", - @"dev.flutter.pigeon.video_player_avfoundation." - @"AVFoundationVideoPlayerApi.getAssetUrl", - messageChannelSuffix] + FlutterBasicMessageChannel *channel = + [[FlutterBasicMessageChannel alloc] + initWithName:[NSString stringWithFormat:@"%@%@", @"dev.flutter.pigeon.video_player_avfoundation.AVFoundationVideoPlayerApi.getAssetUrl", messageChannelSuffix] binaryMessenger:binaryMessenger - codec:FVPGetMessagesCodec()]; + codec:FVPGetMessagesCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector(fileURLForAssetWithName:package:error:)], - @"FVPAVFoundationVideoPlayerApi api (%@) doesn't respond to " - @"@selector(fileURLForAssetWithName:package:error:)", - api); + NSCAssert([api respondsToSelector:@selector(fileURLForAssetWithName:package:error:)], @"FVPAVFoundationVideoPlayerApi api (%@) doesn't respond to @selector(fileURLForAssetWithName:package:error:)", api); [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { NSArray *args = message; NSString *arg_asset = GetNullableObjectAtIndex(args, 0); @@ -298,30 +468,20 @@ void SetUpFVPAVFoundationVideoPlayerApiWithSuffix(id bin } } } -void SetUpFVPVideoPlayerInstanceApi(id binaryMessenger, - NSObject *api) { +void SetUpFVPVideoPlayerInstanceApi(id binaryMessenger, NSObject *api) { SetUpFVPVideoPlayerInstanceApiWithSuffix(binaryMessenger, api, @""); } -void SetUpFVPVideoPlayerInstanceApiWithSuffix(id binaryMessenger, - NSObject *api, - NSString *messageChannelSuffix) { - messageChannelSuffix = messageChannelSuffix.length > 0 - ? [NSString stringWithFormat:@".%@", messageChannelSuffix] - : @""; +void SetUpFVPVideoPlayerInstanceApiWithSuffix(id binaryMessenger, NSObject *api, NSString *messageChannelSuffix) { + messageChannelSuffix = messageChannelSuffix.length > 0 ? [NSString stringWithFormat: @".%@", messageChannelSuffix] : @""; { - FlutterBasicMessageChannel *channel = [[FlutterBasicMessageChannel alloc] - initWithName:[NSString stringWithFormat:@"%@%@", - @"dev.flutter.pigeon.video_player_avfoundation." - @"VideoPlayerInstanceApi.setLooping", - messageChannelSuffix] + FlutterBasicMessageChannel *channel = + [[FlutterBasicMessageChannel alloc] + initWithName:[NSString stringWithFormat:@"%@%@", @"dev.flutter.pigeon.video_player_avfoundation.VideoPlayerInstanceApi.setLooping", messageChannelSuffix] binaryMessenger:binaryMessenger - codec:FVPGetMessagesCodec()]; + codec:FVPGetMessagesCodec()]; if (api) { - NSCAssert( - [api respondsToSelector:@selector(setLooping:error:)], - @"FVPVideoPlayerInstanceApi api (%@) doesn't respond to @selector(setLooping:error:)", - api); + NSCAssert([api respondsToSelector:@selector(setLooping:error:)], @"FVPVideoPlayerInstanceApi api (%@) doesn't respond to @selector(setLooping:error:)", api); [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { NSArray *args = message; BOOL arg_looping = [GetNullableObjectAtIndex(args, 0) boolValue]; @@ -334,18 +494,13 @@ void SetUpFVPVideoPlayerInstanceApiWithSuffix(id binaryM } } { - FlutterBasicMessageChannel *channel = [[FlutterBasicMessageChannel alloc] - initWithName:[NSString stringWithFormat:@"%@%@", - @"dev.flutter.pigeon.video_player_avfoundation." - @"VideoPlayerInstanceApi.setVolume", - messageChannelSuffix] + FlutterBasicMessageChannel *channel = + [[FlutterBasicMessageChannel alloc] + initWithName:[NSString stringWithFormat:@"%@%@", @"dev.flutter.pigeon.video_player_avfoundation.VideoPlayerInstanceApi.setVolume", messageChannelSuffix] binaryMessenger:binaryMessenger - codec:FVPGetMessagesCodec()]; + codec:FVPGetMessagesCodec()]; if (api) { - NSCAssert( - [api respondsToSelector:@selector(setVolume:error:)], - @"FVPVideoPlayerInstanceApi api (%@) doesn't respond to @selector(setVolume:error:)", - api); + NSCAssert([api respondsToSelector:@selector(setVolume:error:)], @"FVPVideoPlayerInstanceApi api (%@) doesn't respond to @selector(setVolume:error:)", api); [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { NSArray *args = message; double arg_volume = [GetNullableObjectAtIndex(args, 0) doubleValue]; @@ -358,18 +513,13 @@ void SetUpFVPVideoPlayerInstanceApiWithSuffix(id binaryM } } { - FlutterBasicMessageChannel *channel = [[FlutterBasicMessageChannel alloc] - initWithName:[NSString stringWithFormat:@"%@%@", - @"dev.flutter.pigeon.video_player_avfoundation." - @"VideoPlayerInstanceApi.setPlaybackSpeed", - messageChannelSuffix] + FlutterBasicMessageChannel *channel = + [[FlutterBasicMessageChannel alloc] + initWithName:[NSString stringWithFormat:@"%@%@", @"dev.flutter.pigeon.video_player_avfoundation.VideoPlayerInstanceApi.setPlaybackSpeed", messageChannelSuffix] binaryMessenger:binaryMessenger - codec:FVPGetMessagesCodec()]; + codec:FVPGetMessagesCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector(setPlaybackSpeed:error:)], - @"FVPVideoPlayerInstanceApi api (%@) doesn't respond to " - @"@selector(setPlaybackSpeed:error:)", - api); + NSCAssert([api respondsToSelector:@selector(setPlaybackSpeed:error:)], @"FVPVideoPlayerInstanceApi api (%@) doesn't respond to @selector(setPlaybackSpeed:error:)", api); [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { NSArray *args = message; double arg_speed = [GetNullableObjectAtIndex(args, 0) doubleValue]; @@ -382,17 +532,13 @@ void SetUpFVPVideoPlayerInstanceApiWithSuffix(id binaryM } } { - FlutterBasicMessageChannel *channel = [[FlutterBasicMessageChannel alloc] - initWithName:[NSString stringWithFormat:@"%@%@", - @"dev.flutter.pigeon.video_player_avfoundation." - @"VideoPlayerInstanceApi.play", - messageChannelSuffix] + FlutterBasicMessageChannel *channel = + [[FlutterBasicMessageChannel alloc] + initWithName:[NSString stringWithFormat:@"%@%@", @"dev.flutter.pigeon.video_player_avfoundation.VideoPlayerInstanceApi.play", messageChannelSuffix] binaryMessenger:binaryMessenger - codec:FVPGetMessagesCodec()]; + codec:FVPGetMessagesCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector(playWithError:)], - @"FVPVideoPlayerInstanceApi api (%@) doesn't respond to @selector(playWithError:)", - api); + NSCAssert([api respondsToSelector:@selector(playWithError:)], @"FVPVideoPlayerInstanceApi api (%@) doesn't respond to @selector(playWithError:)", api); [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { FlutterError *error; [api playWithError:&error]; @@ -403,16 +549,13 @@ void SetUpFVPVideoPlayerInstanceApiWithSuffix(id binaryM } } { - FlutterBasicMessageChannel *channel = [[FlutterBasicMessageChannel alloc] - initWithName:[NSString stringWithFormat:@"%@%@", - @"dev.flutter.pigeon.video_player_avfoundation." - @"VideoPlayerInstanceApi.getPosition", - messageChannelSuffix] + FlutterBasicMessageChannel *channel = + [[FlutterBasicMessageChannel alloc] + initWithName:[NSString stringWithFormat:@"%@%@", @"dev.flutter.pigeon.video_player_avfoundation.VideoPlayerInstanceApi.getPosition", messageChannelSuffix] binaryMessenger:binaryMessenger - codec:FVPGetMessagesCodec()]; + codec:FVPGetMessagesCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector(position:)], - @"FVPVideoPlayerInstanceApi api (%@) doesn't respond to @selector(position:)", api); + NSCAssert([api respondsToSelector:@selector(position:)], @"FVPVideoPlayerInstanceApi api (%@) doesn't respond to @selector(position:)", api); [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { FlutterError *error; NSNumber *output = [api position:&error]; @@ -423,42 +566,32 @@ void SetUpFVPVideoPlayerInstanceApiWithSuffix(id binaryM } } { - FlutterBasicMessageChannel *channel = [[FlutterBasicMessageChannel alloc] - initWithName:[NSString stringWithFormat:@"%@%@", - @"dev.flutter.pigeon.video_player_avfoundation." - @"VideoPlayerInstanceApi.seekTo", - messageChannelSuffix] + FlutterBasicMessageChannel *channel = + [[FlutterBasicMessageChannel alloc] + initWithName:[NSString stringWithFormat:@"%@%@", @"dev.flutter.pigeon.video_player_avfoundation.VideoPlayerInstanceApi.seekTo", messageChannelSuffix] binaryMessenger:binaryMessenger - codec:FVPGetMessagesCodec()]; + codec:FVPGetMessagesCodec()]; if (api) { - NSCAssert( - [api respondsToSelector:@selector(seekTo:completion:)], - @"FVPVideoPlayerInstanceApi api (%@) doesn't respond to @selector(seekTo:completion:)", - api); + NSCAssert([api respondsToSelector:@selector(seekTo:completion:)], @"FVPVideoPlayerInstanceApi api (%@) doesn't respond to @selector(seekTo:completion:)", api); [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { NSArray *args = message; NSInteger arg_position = [GetNullableObjectAtIndex(args, 0) integerValue]; - [api seekTo:arg_position - completion:^(FlutterError *_Nullable error) { - callback(wrapResult(nil, error)); - }]; + [api seekTo:arg_position completion:^(FlutterError *_Nullable error) { + callback(wrapResult(nil, error)); + }]; }]; } else { [channel setMessageHandler:nil]; } } { - FlutterBasicMessageChannel *channel = [[FlutterBasicMessageChannel alloc] - initWithName:[NSString stringWithFormat:@"%@%@", - @"dev.flutter.pigeon.video_player_avfoundation." - @"VideoPlayerInstanceApi.pause", - messageChannelSuffix] + FlutterBasicMessageChannel *channel = + [[FlutterBasicMessageChannel alloc] + initWithName:[NSString stringWithFormat:@"%@%@", @"dev.flutter.pigeon.video_player_avfoundation.VideoPlayerInstanceApi.pause", messageChannelSuffix] binaryMessenger:binaryMessenger - codec:FVPGetMessagesCodec()]; + codec:FVPGetMessagesCodec()]; if (api) { - NSCAssert([api respondsToSelector:@selector(pauseWithError:)], - @"FVPVideoPlayerInstanceApi api (%@) doesn't respond to @selector(pauseWithError:)", - api); + NSCAssert([api respondsToSelector:@selector(pauseWithError:)], @"FVPVideoPlayerInstanceApi api (%@) doesn't respond to @selector(pauseWithError:)", api); [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { FlutterError *error; [api pauseWithError:&error]; @@ -468,4 +601,21 @@ void SetUpFVPVideoPlayerInstanceApiWithSuffix(id binaryM [channel setMessageHandler:nil]; } } + { + FlutterBasicMessageChannel *channel = + [[FlutterBasicMessageChannel alloc] + initWithName:[NSString stringWithFormat:@"%@%@", @"dev.flutter.pigeon.video_player_avfoundation.VideoPlayerInstanceApi.getAudioTracks", messageChannelSuffix] + binaryMessenger:binaryMessenger + codec:FVPGetMessagesCodec()]; + if (api) { + NSCAssert([api respondsToSelector:@selector(getAudioTracks:)], @"FVPVideoPlayerInstanceApi api (%@) doesn't respond to @selector(getAudioTracks:)", api); + [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { + FlutterError *error; + FVPNativeAudioTrackData *output = [api getAudioTracks:&error]; + callback(wrapResult(output, error)); + }]; + } else { + [channel setMessageHandler:nil]; + } + } } diff --git a/packages/video_player/video_player_avfoundation/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/video_player/video_player_avfoundation/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index d7730d34dab..6ef3fa75e7f 100644 --- a/packages/video_player/video_player_avfoundation/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/packages/video_player/video_player_avfoundation/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -44,6 +44,7 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit" shouldUseLaunchSchemeArgsEnv = "YES"> playerViewStates = - {}; + final Map playerViewStates = {}; - final Map _players = - {}; + final Map _players = {}; /// Registers this class as the default instance of [VideoPlayerPlatform]. static void registerWith() { @@ -92,8 +89,7 @@ class AVFoundationVideoPlayer extends VideoPlayerPlatform { // implementation, which threw on the native side. throw PlatformException( code: 'video_player', - message: - 'Asset $asset not found in package ${dataSource.package}.'); + message: 'Asset $asset not found in package ${dataSource.package}.'); } case DataSourceType.network: case DataSourceType.file: @@ -112,8 +108,7 @@ class AVFoundationVideoPlayer extends VideoPlayerPlatform { final int playerId = await _api.create(pigeonCreationOptions); playerViewStates[playerId] = switch (viewType) { // playerId is also the textureId when using texture view. - VideoViewType.textureView => - VideoPlayerTextureViewState(textureId: playerId), + VideoViewType.textureView => VideoPlayerTextureViewState(textureId: playerId), VideoViewType.platformView => const VideoPlayerPlatformViewState(), }; ensureApiInitialized(playerId); @@ -170,9 +165,7 @@ class AVFoundationVideoPlayer extends VideoPlayerPlatform { @override Stream videoEventsFor(int playerId) { - return _eventChannelFor(playerId) - .receiveBroadcastStream() - .map((dynamic event) { + return _eventChannelFor(playerId).receiveBroadcastStream().map((dynamic event) { final Map map = event as Map; return switch (map['event']) { 'initialized' => VideoEvent( @@ -192,8 +185,7 @@ class AVFoundationVideoPlayer extends VideoPlayerPlatform { .toList(), eventType: VideoEventType.bufferingUpdate, ), - 'bufferingStart' => - VideoEvent(eventType: VideoEventType.bufferingStart), + 'bufferingStart' => VideoEvent(eventType: VideoEventType.bufferingStart), 'bufferingEnd' => VideoEvent(eventType: VideoEventType.bufferingEnd), 'isPlayingStateUpdate' => VideoEvent( eventType: VideoEventType.isPlayingStateUpdate, @@ -209,6 +201,79 @@ class AVFoundationVideoPlayer extends VideoPlayerPlatform { return _api.setMixWithOthers(mixWithOthers); } + @override + Future> getAudioTracks(int playerId) async { + final VideoPlayerInstanceApi player = _playerWith(id: playerId); + final NativeAudioTrackData rawData = await player.getAudioTracks(); + + final List tracks = []; + + // Process media selection tracks (HLS streams) + if (rawData.mediaSelectionTracks != null) { + for (int i = 0; i < rawData.mediaSelectionTracks!.length; i++) { + final MediaSelectionAudioTrackData mediaTrack = rawData.mediaSelectionTracks![i]; + + // Generate consistent track ID + final String trackId = 'hls_audio_${mediaTrack.index}'; + + // Determine best label from available data + String label = mediaTrack.commonMetadataTitle ?? + mediaTrack.displayName ?? + 'Audio Track ${i + 1}'; + + // Use language code or fallback + final String language = mediaTrack.languageCode ?? 'und'; + + tracks.add(VideoAudioTrack( + id: trackId, + label: label, + language: language, + isSelected: mediaTrack.isSelected, + // Media selection tracks don't provide detailed metadata + bitrate: null, + sampleRate: null, + channelCount: null, + codec: null, + )); + } + } + + // Process asset tracks (regular video files) + else if (rawData.assetTracks != null) { + for (int i = 0; i < rawData.assetTracks!.length; i++) { + final AssetAudioTrackData assetTrack = rawData.assetTracks![i]; + + // Generate consistent track ID + final String trackId = 'audio_${assetTrack.trackId}'; + + // Determine best label with fallbacks + String label = assetTrack.label ?? 'Audio Track ${i + 1}'; + if (label.isEmpty) { + // Use language as label if available and not 'und' + final String lang = assetTrack.language ?? 'und'; + if (lang != 'und') { + label = lang.toUpperCase(); + } else { + label = 'Audio Track ${i + 1}'; + } + } + + tracks.add(VideoAudioTrack( + id: trackId, + label: label, + language: assetTrack.language ?? 'und', + isSelected: assetTrack.isSelected, + bitrate: assetTrack.bitrate, + sampleRate: assetTrack.sampleRate, + channelCount: assetTrack.channelCount, + codec: assetTrack.codec, + )); + } + } + + return tracks; + } + @override Widget buildView(int playerId) { return buildViewWithOptions( @@ -222,8 +287,7 @@ class AVFoundationVideoPlayer extends VideoPlayerPlatform { final VideoPlayerViewState? viewState = playerViewStates[playerId]; return switch (viewState) { - VideoPlayerTextureViewState(:final int textureId) => - Texture(textureId: textureId), + VideoPlayerTextureViewState(:final int textureId) => Texture(textureId: textureId), VideoPlayerPlatformViewState() => _buildPlatformView(playerId), null => throw Exception( 'Could not find corresponding view type for playerId: $playerId', 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 ae5ec1d9f6c..54597a07288 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 @@ -21,8 +21,7 @@ PlatformException _createConnectionError(String channelName) { bool _deepEquals(Object? a, Object? b) { if (a is List && b is List) { return a.length == b.length && - a.indexed - .every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); + a.indexed.every(((int, dynamic) item) => _deepEquals(item.$2, b[item.$1])); } if (a is Map && b is Map) { return a.length == b.length && @@ -67,8 +66,7 @@ class PlatformVideoViewCreationParams { @override // ignore: avoid_equals_and_hash_code_on_mutable_classes bool operator ==(Object other) { - if (other is! PlatformVideoViewCreationParams || - other.runtimeType != runtimeType) { + if (other is! PlatformVideoViewCreationParams || other.runtimeType != runtimeType) { return false; } if (identical(this, other)) { @@ -111,8 +109,7 @@ class CreationOptions { result as List; return CreationOptions( uri: result[0]! as String, - httpHeaders: - (result[1] as Map?)!.cast(), + httpHeaders: (result[1] as Map?)!.cast(), viewType: result[2]! as PlatformVideoViewType, ); } @@ -134,6 +131,272 @@ class CreationOptions { int get hashCode => Object.hashAll(_toList()); } +/// Represents an audio track in a video. +class AudioTrackMessage { + AudioTrackMessage({ + required this.id, + required this.label, + required this.language, + required this.isSelected, + this.bitrate, + this.sampleRate, + this.channelCount, + this.codec, + }); + + String id; + + String label; + + String language; + + bool isSelected; + + int? bitrate; + + int? sampleRate; + + int? channelCount; + + String? codec; + + List _toList() { + return [ + id, + label, + language, + isSelected, + bitrate, + sampleRate, + channelCount, + codec, + ]; + } + + Object encode() { + return _toList(); + } + + static AudioTrackMessage decode(Object result) { + result as List; + return AudioTrackMessage( + id: result[0]! as String, + label: result[1]! as String, + language: result[2]! as String, + isSelected: result[3]! as bool, + bitrate: result[4] as int?, + sampleRate: result[5] as int?, + channelCount: result[6] as int?, + codec: result[7] as String?, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! AudioTrackMessage || 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()); +} + +/// Raw audio track data from AVAssetTrack (for regular assets). +class AssetAudioTrackData { + AssetAudioTrackData({ + required this.trackId, + this.label, + this.language, + required this.isSelected, + this.bitrate, + this.sampleRate, + this.channelCount, + this.codec, + }); + + int trackId; + + String? label; + + String? language; + + bool isSelected; + + int? bitrate; + + int? sampleRate; + + int? channelCount; + + String? codec; + + List _toList() { + return [ + trackId, + label, + language, + isSelected, + bitrate, + sampleRate, + channelCount, + codec, + ]; + } + + Object encode() { + return _toList(); + } + + static AssetAudioTrackData decode(Object result) { + result as List; + return AssetAudioTrackData( + trackId: result[0]! as int, + label: result[1] as String?, + language: result[2] as String?, + isSelected: result[3]! as bool, + bitrate: result[4] as int?, + sampleRate: result[5] as int?, + channelCount: result[6] as int?, + codec: result[7] as String?, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! AssetAudioTrackData || 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()); +} + +/// Raw audio track data from AVMediaSelectionOption (for HLS streams). +class MediaSelectionAudioTrackData { + MediaSelectionAudioTrackData({ + required this.index, + this.displayName, + this.languageCode, + required this.isSelected, + this.commonMetadataTitle, + }); + + int index; + + String? displayName; + + String? languageCode; + + bool isSelected; + + String? commonMetadataTitle; + + List _toList() { + return [ + index, + displayName, + languageCode, + isSelected, + commonMetadataTitle, + ]; + } + + Object encode() { + return _toList(); + } + + static MediaSelectionAudioTrackData decode(Object result) { + result as List; + return MediaSelectionAudioTrackData( + index: result[0]! as int, + displayName: result[1] as String?, + languageCode: result[2] as String?, + isSelected: result[3]! as bool, + commonMetadataTitle: result[4] as String?, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! MediaSelectionAudioTrackData || 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 audio track data from native platforms. +class NativeAudioTrackData { + NativeAudioTrackData({ + this.assetTracks, + this.mediaSelectionTracks, + }); + + /// Asset-based tracks (for regular video files) + List? assetTracks; + + /// Media selection-based tracks (for HLS streams) + List? mediaSelectionTracks; + + List _toList() { + return [ + assetTracks, + mediaSelectionTracks, + ]; + } + + Object encode() { + return _toList(); + } + + static NativeAudioTrackData decode(Object result) { + result as List; + return NativeAudioTrackData( + 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! NativeAudioTrackData || 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 @@ -150,6 +413,18 @@ class _PigeonCodec extends StandardMessageCodec { } else if (value is CreationOptions) { buffer.putUint8(131); writeValue(buffer, value.encode()); + } else if (value is AudioTrackMessage) { + buffer.putUint8(132); + writeValue(buffer, value.encode()); + } else if (value is AssetAudioTrackData) { + buffer.putUint8(133); + writeValue(buffer, value.encode()); + } else if (value is MediaSelectionAudioTrackData) { + buffer.putUint8(134); + writeValue(buffer, value.encode()); + } else if (value is NativeAudioTrackData) { + buffer.putUint8(135); + writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); } @@ -165,6 +440,14 @@ class _PigeonCodec extends StandardMessageCodec { return PlatformVideoViewCreationParams.decode(readValue(buffer)!); case 131: return CreationOptions.decode(readValue(buffer)!); + case 132: + return AudioTrackMessage.decode(readValue(buffer)!); + case 133: + return AssetAudioTrackData.decode(readValue(buffer)!); + case 134: + return MediaSelectionAudioTrackData.decode(readValue(buffer)!); + case 135: + return NativeAudioTrackData.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); } @@ -189,8 +472,7 @@ class AVFoundationVideoPlayerApi { Future initialize() async { final String pigeonVar_channelName = 'dev.flutter.pigeon.video_player_avfoundation.AVFoundationVideoPlayerApi.initialize$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, @@ -214,8 +496,7 @@ class AVFoundationVideoPlayerApi { Future create(CreationOptions creationOptions) async { final String pigeonVar_channelName = 'dev.flutter.pigeon.video_player_avfoundation.AVFoundationVideoPlayerApi.create$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, @@ -245,8 +526,7 @@ class AVFoundationVideoPlayerApi { Future dispose(int playerId) async { final String pigeonVar_channelName = 'dev.flutter.pigeon.video_player_avfoundation.AVFoundationVideoPlayerApi.dispose$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, @@ -271,8 +551,7 @@ class AVFoundationVideoPlayerApi { Future setMixWithOthers(bool mixWithOthers) async { final String pigeonVar_channelName = 'dev.flutter.pigeon.video_player_avfoundation.AVFoundationVideoPlayerApi.setMixWithOthers$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, @@ -297,8 +576,7 @@ class AVFoundationVideoPlayerApi { Future getAssetUrl(String asset, String? package) async { final String pigeonVar_channelName = 'dev.flutter.pigeon.video_player_avfoundation.AVFoundationVideoPlayerApi.getAssetUrl$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, @@ -339,8 +617,7 @@ class VideoPlayerInstanceApi { Future setLooping(bool looping) async { final String pigeonVar_channelName = 'dev.flutter.pigeon.video_player_avfoundation.VideoPlayerInstanceApi.setLooping$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, @@ -365,8 +642,7 @@ class VideoPlayerInstanceApi { Future setVolume(double volume) async { final String pigeonVar_channelName = 'dev.flutter.pigeon.video_player_avfoundation.VideoPlayerInstanceApi.setVolume$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, @@ -391,14 +667,12 @@ class VideoPlayerInstanceApi { Future setPlaybackSpeed(double speed) async { final String pigeonVar_channelName = 'dev.flutter.pigeon.video_player_avfoundation.VideoPlayerInstanceApi.setPlaybackSpeed$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); - final Future pigeonVar_sendFuture = - pigeonVar_channel.send([speed]); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([speed]); final List? pigeonVar_replyList = await pigeonVar_sendFuture as List?; if (pigeonVar_replyList == null) { @@ -417,8 +691,7 @@ class VideoPlayerInstanceApi { Future play() async { final String pigeonVar_channelName = 'dev.flutter.pigeon.video_player_avfoundation.VideoPlayerInstanceApi.play$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, @@ -442,8 +715,7 @@ class VideoPlayerInstanceApi { Future getPosition() async { final String pigeonVar_channelName = 'dev.flutter.pigeon.video_player_avfoundation.VideoPlayerInstanceApi.getPosition$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, @@ -472,8 +744,7 @@ class VideoPlayerInstanceApi { Future seekTo(int position) async { final String pigeonVar_channelName = 'dev.flutter.pigeon.video_player_avfoundation.VideoPlayerInstanceApi.seekTo$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, @@ -498,8 +769,7 @@ class VideoPlayerInstanceApi { Future pause() async { final String pigeonVar_channelName = 'dev.flutter.pigeon.video_player_avfoundation.VideoPlayerInstanceApi.pause$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, @@ -519,4 +789,33 @@ class VideoPlayerInstanceApi { return; } } + + Future getAudioTracks() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.video_player_avfoundation.VideoPlayerInstanceApi.getAudioTracks$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 NativeAudioTrackData?)!; + } + } } diff --git a/packages/video_player/video_player_avfoundation/pigeons/messages.dart b/packages/video_player/video_player_avfoundation/pigeons/messages.dart index 4b336cca5e6..a18f3b452a5 100644 --- a/packages/video_player/video_player_avfoundation/pigeons/messages.dart +++ b/packages/video_player/video_player_avfoundation/pigeons/messages.dart @@ -44,6 +44,83 @@ class CreationOptions { PlatformVideoViewType viewType; } +/// Represents an audio track in a video. +class AudioTrackMessage { + AudioTrackMessage({ + required this.id, + required this.label, + required this.language, + required this.isSelected, + this.bitrate, + this.sampleRate, + this.channelCount, + this.codec, + }); + + String id; + String label; + String language; + bool isSelected; + int? bitrate; + int? sampleRate; + int? channelCount; + String? codec; +} + +/// Raw audio track data from AVAssetTrack (for regular assets). +class AssetAudioTrackData { + AssetAudioTrackData({ + required this.trackId, + this.label, + this.language, + required this.isSelected, + this.bitrate, + this.sampleRate, + this.channelCount, + this.codec, + }); + + int trackId; + String? label; + String? language; + bool isSelected; + int? bitrate; + int? sampleRate; + int? channelCount; + String? codec; +} + +/// Raw audio track data from AVMediaSelectionOption (for HLS streams). +class MediaSelectionAudioTrackData { + MediaSelectionAudioTrackData({ + required this.index, + this.displayName, + this.languageCode, + required this.isSelected, + this.commonMetadataTitle, + }); + + int index; + String? displayName; + String? languageCode; + bool isSelected; + String? commonMetadataTitle; +} + +/// Container for raw audio track data from native platforms. +class NativeAudioTrackData { + NativeAudioTrackData({ + this.assetTracks, + this.mediaSelectionTracks, + }); + + /// Asset-based tracks (for regular video files) + List? assetTracks; + + /// Media selection-based tracks (for HLS streams) + List? mediaSelectionTracks; +} + @HostApi() abstract class AVFoundationVideoPlayerApi { @ObjCSelector('initialize') @@ -74,4 +151,6 @@ abstract class VideoPlayerInstanceApi { @ObjCSelector('seekTo:') void seekTo(int position); void pause(); + @ObjCSelector('getAudioTracks') + NativeAudioTrackData getAudioTracks(); } diff --git a/packages/video_player/video_player_avfoundation/pubspec.yaml b/packages/video_player/video_player_avfoundation/pubspec.yaml index a9d17a56b67..a7fec7cc8e4 100644 --- a/packages/video_player/video_player_avfoundation/pubspec.yaml +++ b/packages/video_player/video_player_avfoundation/pubspec.yaml @@ -2,7 +2,7 @@ name: video_player_avfoundation description: iOS and macOS implementation of the video_player plugin. repository: https://github.com/flutter/packages/tree/main/packages/video_player/video_player_avfoundation issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+video_player%22 -version: 2.8.3 +version: 2.9.0 environment: sdk: ^3.6.0 @@ -36,3 +36,7 @@ dev_dependencies: topics: - video - video-player +# FOR TESTING AND INITIAL REVIEW ONLY. DO NOT MERGE. +# See https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#changing-federated-plugins +dependency_overrides: + video_player_platform_interface: {path: ../../../packages/video_player/video_player_platform_interface} 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 fb367310ec1..fd09794f228 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 @@ -19,13 +19,9 @@ import 'avfoundation_video_player_test.mocks.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - ( - AVFoundationVideoPlayer, - MockAVFoundationVideoPlayerApi, - MockVideoPlayerInstanceApi - ) setUpMockPlayer({required int playerId}) { - final MockAVFoundationVideoPlayerApi pluginApi = - MockAVFoundationVideoPlayerApi(); + (AVFoundationVideoPlayer, MockAVFoundationVideoPlayerApi, MockVideoPlayerInstanceApi) + setUpMockPlayer({required int playerId}) { + final MockAVFoundationVideoPlayerApi pluginApi = MockAVFoundationVideoPlayerApi(); final MockVideoPlayerInstanceApi instanceApi = MockVideoPlayerInstanceApi(); final AVFoundationVideoPlayer player = AVFoundationVideoPlayer( pluginApi: pluginApi, @@ -89,16 +85,14 @@ void main() { ); final VerificationResult verification = verify(api.create(captureAny)); - final CreationOptions creationOptions = - verification.captured[0] as CreationOptions; + final CreationOptions creationOptions = verification.captured[0] as CreationOptions; expect(creationOptions.uri, assetUrl); expect(playerId, newPlayerId); expect(player.playerViewStates[newPlayerId], const VideoPlayerTextureViewState(textureId: newPlayerId)); }); - test('create with asset throws PlatformException for missing asset', - () async { + test('create with asset throws PlatformException for missing asset', () async { final ( AVFoundationVideoPlayer player, MockAVFoundationVideoPlayerApi api, @@ -141,8 +135,7 @@ void main() { ); final VerificationResult verification = verify(api.create(captureAny)); - final CreationOptions creationOptions = - verification.captured[0] as CreationOptions; + final CreationOptions creationOptions = verification.captured[0] as CreationOptions; expect(creationOptions.uri, uri); expect(creationOptions.httpHeaders, {}); expect(playerId, newPlayerId); @@ -169,8 +162,7 @@ void main() { ), ); final VerificationResult verification = verify(api.create(captureAny)); - final CreationOptions creationOptions = - verification.captured[0] as CreationOptions; + final CreationOptions creationOptions = verification.captured[0] as CreationOptions; expect(creationOptions.httpHeaders, headers); }); @@ -188,8 +180,7 @@ void main() { DataSource(sourceType: DataSourceType.file, uri: fileUri), ); final VerificationResult verification = verify(api.create(captureAny)); - final CreationOptions creationOptions = - verification.captured[0] as CreationOptions; + final CreationOptions creationOptions = verification.captured[0] as CreationOptions; expect(creationOptions.uri, fileUri); expect(playerId, newPlayerId); expect(player.playerViewStates[newPlayerId], @@ -223,8 +214,7 @@ void main() { ); final VerificationResult verification = verify(api.create(captureAny)); - final CreationOptions creationOptions = - verification.captured[0] as CreationOptions; + final CreationOptions creationOptions = verification.captured[0] as CreationOptions; expect(creationOptions.uri, assetUrl); expect(playerId, newPlayerId); expect(player.playerViewStates[newPlayerId], @@ -253,8 +243,7 @@ void main() { ); final VerificationResult verification = verify(api.create(captureAny)); - final CreationOptions creationOptions = - verification.captured[0] as CreationOptions; + final CreationOptions creationOptions = verification.captured[0] as CreationOptions; expect(creationOptions.uri, uri); expect(creationOptions.httpHeaders, {}); expect(playerId, newPlayerId); @@ -286,8 +275,7 @@ void main() { ); final VerificationResult verification = verify(api.create(captureAny)); - final CreationOptions creationOptions = - verification.captured[0] as CreationOptions; + final CreationOptions creationOptions = verification.captured[0] as CreationOptions; expect(creationOptions.httpHeaders, headers); expect(playerId, newPlayerId); }); @@ -310,8 +298,7 @@ void main() { ); final VerificationResult verification = verify(api.create(captureAny)); - final CreationOptions creationOptions = - verification.captured[0] as CreationOptions; + final CreationOptions creationOptions = verification.captured[0] as CreationOptions; expect(creationOptions.uri, fileUri); expect(playerId, newPlayerId); expect(player.playerViewStates[newPlayerId], @@ -338,12 +325,10 @@ void main() { ); final VerificationResult verification = verify(api.create(captureAny)); - final CreationOptions creationOptions = - verification.captured[0] as CreationOptions; + final CreationOptions creationOptions = verification.captured[0] as CreationOptions; expect(creationOptions.viewType, PlatformVideoViewType.platformView); expect(playerId, newPlayerId); - expect(player.playerViewStates[newPlayerId], - const VideoPlayerPlatformViewState()); + expect(player.playerViewStates[newPlayerId], const VideoPlayerPlatformViewState()); }); test('setLooping', () async { @@ -471,12 +456,10 @@ void main() { final MethodCall methodCall = const StandardMethodCodec().decodeMethodCall(message); if (methodCall.method == 'listen') { - await TestDefaultBinaryMessengerBinding - .instance.defaultBinaryMessenger + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .handlePlatformMessage( mockChannel, - const StandardMethodCodec() - .encodeSuccessEnvelope({ + const StandardMethodCodec().encodeSuccessEnvelope({ 'event': 'initialized', 'duration': 98765, 'width': 1920, @@ -484,22 +467,18 @@ void main() { }), (ByteData? data) {}); - await TestDefaultBinaryMessengerBinding - .instance.defaultBinaryMessenger + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .handlePlatformMessage( mockChannel, - const StandardMethodCodec() - .encodeSuccessEnvelope({ + const StandardMethodCodec().encodeSuccessEnvelope({ 'event': 'completed', }), (ByteData? data) {}); - await TestDefaultBinaryMessengerBinding - .instance.defaultBinaryMessenger + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .handlePlatformMessage( mockChannel, - const StandardMethodCodec() - .encodeSuccessEnvelope({ + const StandardMethodCodec().encodeSuccessEnvelope({ 'event': 'bufferingUpdate', 'values': >[ [0, 1234], @@ -508,43 +487,35 @@ void main() { }), (ByteData? data) {}); - await TestDefaultBinaryMessengerBinding - .instance.defaultBinaryMessenger + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .handlePlatformMessage( mockChannel, - const StandardMethodCodec() - .encodeSuccessEnvelope({ + const StandardMethodCodec().encodeSuccessEnvelope({ 'event': 'bufferingStart', }), (ByteData? data) {}); - await TestDefaultBinaryMessengerBinding - .instance.defaultBinaryMessenger + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .handlePlatformMessage( mockChannel, - const StandardMethodCodec() - .encodeSuccessEnvelope({ + const StandardMethodCodec().encodeSuccessEnvelope({ 'event': 'bufferingEnd', }), (ByteData? data) {}); - await TestDefaultBinaryMessengerBinding - .instance.defaultBinaryMessenger + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .handlePlatformMessage( mockChannel, - const StandardMethodCodec() - .encodeSuccessEnvelope({ + const StandardMethodCodec().encodeSuccessEnvelope({ 'event': 'isPlayingStateUpdate', 'isPlaying': true, }), (ByteData? data) {}); - await TestDefaultBinaryMessengerBinding - .instance.defaultBinaryMessenger + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .handlePlatformMessage( mockChannel, - const StandardMethodCodec() - .encodeSuccessEnvelope({ + const StandardMethodCodec().encodeSuccessEnvelope({ 'event': 'isPlayingStateUpdate', 'isPlaying': false, }), @@ -591,5 +562,399 @@ void main() { ), ])); }); + + group('getAudioTracks', () { + test('returns audio tracks with complete metadata', () async { + final ( + AVFoundationVideoPlayer player, + _, + MockVideoPlayerInstanceApi instanceApi, + ) = setUpMockPlayer(playerId: 1); + + final NativeAudioTrackData mockData = NativeAudioTrackData( + assetTracks: [ + AssetAudioTrackData( + trackId: 1, + label: 'English', + language: 'en', + isSelected: true, + bitrate: 128000, + sampleRate: 48000, + channelCount: 2, + codec: 'aac', + ), + AssetAudioTrackData( + trackId: 2, + label: 'French', + language: 'fr', + isSelected: false, + bitrate: 96000, + sampleRate: 44100, + channelCount: 2, + codec: 'aac', + ), + ], + ); + + when(instanceApi.getAudioTracks()).thenAnswer((_) async => mockData); + + final List tracks = await player.getAudioTracks(1); + + expect(tracks, hasLength(2)); + + expect(tracks[0].id, 'audio_1'); + expect(tracks[0].label, 'English'); + expect(tracks[0].language, 'en'); + expect(tracks[0].isSelected, true); + expect(tracks[0].bitrate, 128000); + expect(tracks[0].sampleRate, 48000); + expect(tracks[0].channelCount, 2); + expect(tracks[0].codec, 'aac'); + + expect(tracks[1].id, 'audio_2'); + expect(tracks[1].label, 'French'); + expect(tracks[1].language, 'fr'); + expect(tracks[1].isSelected, false); + expect(tracks[1].bitrate, 96000); + expect(tracks[1].sampleRate, 44100); + expect(tracks[1].channelCount, 2); + expect(tracks[1].codec, 'aac'); + + verify(instanceApi.getAudioTracks()).called(1); + }); + + test('returns audio tracks with partial metadata from HLS streams', () async { + final ( + AVFoundationVideoPlayer player, + _, + MockVideoPlayerInstanceApi instanceApi, + ) = setUpMockPlayer(playerId: 1); + + final NativeAudioTrackData mockData = NativeAudioTrackData( + mediaSelectionTracks: [ + MediaSelectionAudioTrackData( + index: 0, + displayName: 'Default Audio', + languageCode: 'und', + isSelected: true, + commonMetadataTitle: 'Default Audio', + ), + MediaSelectionAudioTrackData( + index: 1, + displayName: 'High Quality', + languageCode: 'en', + isSelected: false, + commonMetadataTitle: 'High Quality', + ), + ], + ); + + when(instanceApi.getAudioTracks()).thenAnswer((_) async => mockData); + + final List tracks = await player.getAudioTracks(1); + + expect(tracks, hasLength(2)); + + expect(tracks[0].id, 'hls_audio_0'); + expect(tracks[0].label, 'Default Audio'); + expect(tracks[0].language, 'und'); + expect(tracks[0].isSelected, true); + expect(tracks[0].bitrate, null); + expect(tracks[0].sampleRate, null); + expect(tracks[0].channelCount, null); + expect(tracks[0].codec, null); + + expect(tracks[1].id, 'hls_audio_1'); + expect(tracks[1].label, 'High Quality'); + expect(tracks[1].language, 'en'); + expect(tracks[1].isSelected, false); + expect(tracks[1].bitrate, null); + expect(tracks[1].sampleRate, null); + expect(tracks[1].channelCount, null); + expect(tracks[1].codec, null); + + verify(instanceApi.getAudioTracks()).called(1); + }); + + test('returns empty list when no audio tracks available', () async { + final ( + AVFoundationVideoPlayer player, + _, + MockVideoPlayerInstanceApi instanceApi, + ) = setUpMockPlayer(playerId: 1); + + final NativeAudioTrackData mockData = NativeAudioTrackData(); + + when(instanceApi.getAudioTracks()).thenAnswer((_) async => mockData); + + final List tracks = await player.getAudioTracks(1); + + expect(tracks, isEmpty); + verify(instanceApi.getAudioTracks()).called(1); + }); + + test('handles AVFoundation specific channel configurations', () async { + final ( + AVFoundationVideoPlayer player, + _, + MockVideoPlayerInstanceApi instanceApi, + ) = setUpMockPlayer(playerId: 1); + + final NativeAudioTrackData mockData = NativeAudioTrackData( + assetTracks: [ + AssetAudioTrackData( + trackId: 1, + label: 'Mono Commentary', + language: 'en', + isSelected: false, + bitrate: 64000, + sampleRate: 22050, + channelCount: 1, + codec: 'aac', + ), + AssetAudioTrackData( + trackId: 2, + label: 'Stereo Music', + language: 'en', + isSelected: true, + bitrate: 128000, + sampleRate: 44100, + channelCount: 2, + codec: 'aac', + ), + AssetAudioTrackData( + trackId: 3, + label: '5.1 Surround', + language: 'en', + isSelected: false, + bitrate: 384000, + sampleRate: 48000, + channelCount: 6, + codec: 'ac-3', + ), + ], + ); + + when(instanceApi.getAudioTracks()).thenAnswer((_) async => mockData); + + final List tracks = await player.getAudioTracks(1); + + expect(tracks, hasLength(3)); + expect(tracks[0].channelCount, 1); + expect(tracks[1].channelCount, 2); + expect(tracks[2].channelCount, 6); + expect(tracks[2].codec, 'ac-3'); // AVFoundation specific codec format + + verify(instanceApi.getAudioTracks()).called(1); + }); + + test('handles different sample rates common in iOS', () async { + final ( + AVFoundationVideoPlayer player, + _, + MockVideoPlayerInstanceApi instanceApi, + ) = setUpMockPlayer(playerId: 1); + + final NativeAudioTrackData mockData = NativeAudioTrackData( + assetTracks: [ + AssetAudioTrackData( + trackId: 1, + label: 'Low Quality', + language: 'en', + isSelected: false, + bitrate: 32000, + sampleRate: 22050, + channelCount: 1, + codec: 'aac', + ), + AssetAudioTrackData( + trackId: 2, + label: 'CD Quality', + language: 'en', + isSelected: true, + bitrate: 128000, + sampleRate: 44100, + channelCount: 2, + codec: 'aac', + ), + AssetAudioTrackData( + trackId: 3, + label: 'High Resolution', + language: 'en', + isSelected: false, + bitrate: 256000, + sampleRate: 48000, + channelCount: 2, + codec: 'aac', + ), + AssetAudioTrackData( + trackId: 4, + label: 'Studio Quality', + language: 'en', + isSelected: false, + bitrate: 320000, + sampleRate: 96000, + channelCount: 2, + codec: 'alac', + ), + ], + ); + + when(instanceApi.getAudioTracks()).thenAnswer((_) async => mockData); + + final List tracks = await player.getAudioTracks(1); + + expect(tracks, hasLength(4)); + expect(tracks[0].sampleRate, 22050); + expect(tracks[1].sampleRate, 44100); + expect(tracks[2].sampleRate, 48000); + expect(tracks[3].sampleRate, 96000); + expect(tracks[3].codec, 'alac'); // Apple Lossless codec + + verify(instanceApi.getAudioTracks()).called(1); + }); + + test('handles multilingual tracks typical in iOS apps', () async { + final ( + AVFoundationVideoPlayer player, + _, + MockVideoPlayerInstanceApi instanceApi, + ) = setUpMockPlayer(playerId: 1); + + final NativeAudioTrackData mockData = NativeAudioTrackData( + assetTracks: [ + AssetAudioTrackData( + trackId: 1, + label: 'English', + language: 'en', + isSelected: true, + bitrate: 128000, + sampleRate: 48000, + channelCount: 2, + codec: 'aac', + ), + AssetAudioTrackData( + trackId: 2, + label: 'Español', + language: 'es', + isSelected: false, + bitrate: 128000, + sampleRate: 48000, + channelCount: 2, + codec: 'aac', + ), + AssetAudioTrackData( + trackId: 3, + label: 'Français', + language: 'fr', + isSelected: false, + bitrate: 128000, + sampleRate: 48000, + channelCount: 2, + codec: 'aac', + ), + AssetAudioTrackData( + trackId: 4, + label: '日本語', + language: 'ja', + isSelected: false, + bitrate: 128000, + sampleRate: 48000, + channelCount: 2, + codec: 'aac', + ), + ], + ); + + when(instanceApi.getAudioTracks()).thenAnswer((_) async => mockData); + + final List tracks = await player.getAudioTracks(1); + + expect(tracks, hasLength(4)); + expect(tracks[0].language, 'en'); + expect(tracks[1].language, 'es'); + expect(tracks[2].language, 'fr'); + expect(tracks[3].language, 'ja'); + expect(tracks[3].label, '日本語'); // Unicode support + + verify(instanceApi.getAudioTracks()).called(1); + }); + + test('throws PlatformException when AVFoundation method fails', () async { + final ( + AVFoundationVideoPlayer player, + _, + MockVideoPlayerInstanceApi instanceApi, + ) = setUpMockPlayer(playerId: 1); + + when(instanceApi.getAudioTracks()).thenThrow( + PlatformException( + code: 'AVFOUNDATION_ERROR', + message: 'Failed to retrieve audio tracks from AVAsset', + ), + ); + + expect( + () => player.getAudioTracks(1), + throwsA(isA()), + ); + + verify(instanceApi.getAudioTracks()).called(1); + }); + + test('handles tracks with AVFoundation specific codec identifiers', () async { + final ( + AVFoundationVideoPlayer player, + _, + MockVideoPlayerInstanceApi instanceApi, + ) = setUpMockPlayer(playerId: 1); + + final NativeAudioTrackData mockData = NativeAudioTrackData( + assetTracks: [ + AssetAudioTrackData( + trackId: 1, + label: 'AAC Audio', + language: 'en', + isSelected: true, + bitrate: 128000, + sampleRate: 48000, + channelCount: 2, + codec: 'mp4a.40.2', // AAC-LC in AVFoundation format + ), + AssetAudioTrackData( + trackId: 2, + label: 'Apple Lossless', + language: 'en', + isSelected: false, + bitrate: 1000000, + sampleRate: 48000, + channelCount: 2, + codec: 'alac', + ), + AssetAudioTrackData( + trackId: 3, + label: 'Dolby Digital', + language: 'en', + isSelected: false, + bitrate: 384000, + sampleRate: 48000, + channelCount: 6, + codec: 'ac-3', + ), + ], + ); + + when(instanceApi.getAudioTracks()).thenAnswer((_) async => mockData); + + final List tracks = await player.getAudioTracks(1); + + expect(tracks, hasLength(3)); + expect(tracks[0].codec, 'mp4a.40.2'); + expect(tracks[1].codec, 'alac'); + expect(tracks[2].codec, 'ac-3'); + + verify(instanceApi.getAudioTracks()).called(1); + }); + }); }); } 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 76199b56c02..5d102db8bee 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 @@ -23,6 +23,17 @@ import 'package:video_player_avfoundation/src/messages.g.dart' as _i2; // ignore_for_file: camel_case_types // ignore_for_file: subtype_of_sealed_class +class _FakeNativeAudioTrackData_0 extends _i1.SmartFake + implements _i2.NativeAudioTrackData { + _FakeNativeAudioTrackData_0( + Object parent, + Invocation parentInvocation, + ) : super( + parent, + parentInvocation, + ); +} + /// A class which mocks [AVFoundationVideoPlayerApi]. /// /// See the documentation for Mockito's code generation for more information. @@ -52,8 +63,7 @@ class MockAVFoundationVideoPlayerApi extends _i1.Mock ) as _i4.Future); @override - _i4.Future create(_i2.CreationOptions? creationOptions) => - (super.noSuchMethod( + _i4.Future create(_i2.CreationOptions? creationOptions) => (super.noSuchMethod( Invocation.method( #create, [creationOptions], @@ -103,8 +113,7 @@ class MockAVFoundationVideoPlayerApi extends _i1.Mock /// A class which mocks [VideoPlayerInstanceApi]. /// /// See the documentation for Mockito's code generation for more information. -class MockVideoPlayerInstanceApi extends _i1.Mock - implements _i2.VideoPlayerInstanceApi { +class MockVideoPlayerInstanceApi extends _i1.Mock implements _i2.VideoPlayerInstanceApi { @override String get pigeonVar_messageChannelSuffix => (super.noSuchMethod( Invocation.getter(#pigeonVar_messageChannelSuffix), @@ -187,4 +196,28 @@ class MockVideoPlayerInstanceApi extends _i1.Mock returnValue: _i4.Future.value(), returnValueForMissingStub: _i4.Future.value(), ) as _i4.Future); + + @override + _i4.Future<_i2.NativeAudioTrackData> getAudioTracks() => (super.noSuchMethod( + Invocation.method( + #getAudioTracks, + [], + ), + returnValue: + _i4.Future<_i2.NativeAudioTrackData>.value(_FakeNativeAudioTrackData_0( + this, + Invocation.method( + #getAudioTracks, + [], + ), + )), + returnValueForMissingStub: + _i4.Future<_i2.NativeAudioTrackData>.value(_FakeNativeAudioTrackData_0( + this, + Invocation.method( + #getAudioTracks, + [], + ), + )), + ) as _i4.Future<_i2.NativeAudioTrackData>); } diff --git a/packages/video_player/video_player_platform_interface/CHANGELOG.md b/packages/video_player/video_player_platform_interface/CHANGELOG.md index bc285f9a1ba..ffa6aa08f7e 100644 --- a/packages/video_player/video_player_platform_interface/CHANGELOG.md +++ b/packages/video_player/video_player_platform_interface/CHANGELOG.md @@ -1,3 +1,7 @@ +## 6.5.0 + +* Adds audio track metadata support including bitrate, sample rate, channel count, and codec information. + ## 6.4.0 * Adds HTML5 video poster support as a VideoPlayerWebOptions. 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 fe4b9210b7f..ca12a668424 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 @@ -121,6 +121,11 @@ abstract class VideoPlayerPlatform extends PlatformInterface { Future setWebOptions(int playerId, VideoPlayerWebOptions options) { throw UnimplementedError('setWebOptions() has not been implemented.'); } + + /// Gets the available audio tracks for the video. + Future> getAudioTracks(int playerId) { + throw UnimplementedError('getAudioTracks() has not been implemented.'); + } } class _PlaceholderImplementation extends VideoPlayerPlatform {} @@ -531,3 +536,91 @@ class VideoCreationOptions { /// The type of view to be used for displaying the video player final VideoViewType viewType; } + +/// Represents an audio track in a video. +@immutable +class VideoAudioTrack { + /// Creates a new [VideoAudioTrack]. + const VideoAudioTrack({ + required this.id, + required this.label, + required this.language, + this.isSelected = false, + this.bitrate, + this.sampleRate, + this.channelCount, + this.codec, + }); + + /// The unique identifier for this audio track. + final String id; + + /// The display label for this audio track. + final String label; + + /// The language code for this audio track (e.g., 'en', 'es', 'fr'). + final String language; + + /// Whether this audio track is currently selected. + final bool isSelected; + + /// The bitrate of this audio track in bits per second. + /// + /// This represents the quality/bandwidth of the audio stream. + /// Common values: 64000 (64kbps), 128000 (128kbps), 256000 (256kbps). + /// May be null if the information is not available. + final int? bitrate; + + /// The sample rate of this audio track in Hz. + /// + /// Common values: 44100 (44.1kHz), 48000 (48kHz). + /// May be null if the information is not available. + final int? sampleRate; + + /// The number of audio channels in this track. + /// + /// Common values: 1 (mono), 2 (stereo), 6 (5.1 surround), 8 (7.1 surround). + /// May be null if the information is not available. + final int? channelCount; + + /// The audio codec used for this track. + /// + /// Common values: 'aac', 'mp3', 'opus', 'ac3', 'eac3'. + /// May be null if the information is not available. + final String? codec; + + + + @override + bool operator ==(Object other) => + identical(this, other) || + other is VideoAudioTrack && + runtimeType == other.runtimeType && + id == other.id && + label == other.label && + language == other.language && + isSelected == other.isSelected && + bitrate == other.bitrate && + sampleRate == other.sampleRate && + channelCount == other.channelCount && + codec == other.codec; + + @override + int get hashCode => Object.hash( + id, + label, + language, + isSelected, + bitrate, + sampleRate, + channelCount, + codec, + ); + + @override + String toString() { + return 'VideoAudioTrack{id: $id, label: $label, language: $language, ' + 'isSelected: $isSelected, bitrate: $bitrate, sampleRate: $sampleRate, ' + 'channelCount: $channelCount, codec: $codec}'; + } +} diff --git a/packages/video_player/video_player_platform_interface/pubspec.yaml b/packages/video_player/video_player_platform_interface/pubspec.yaml index b3ae08338ba..803f70bb62b 100644 --- a/packages/video_player/video_player_platform_interface/pubspec.yaml +++ b/packages/video_player/video_player_platform_interface/pubspec.yaml @@ -4,7 +4,7 @@ repository: https://github.com/flutter/packages/tree/main/packages/video_player/ issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+video_player%22 # NOTE: We strongly prefer non-breaking changes, even at the expense of a # less-clean API. See https://flutter.dev/go/platform-interface-breaking-changes -version: 6.4.0 +version: 6.5.0 environment: sdk: ^3.6.0