Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:collection';

import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';

Expand All @@ -20,8 +22,12 @@ class _AudioTracksDemoState extends State<AudioTracksDemo> {
bool _isLoading = false;
String? _error;

// Track previous state to detect relevant changes
bool _wasPlaying = false;
bool _wasInitialized = false;

// Sample video URLs with multiple audio tracks
final List<String> _sampleVideos = <String>[
static const List<String> _sampleVideos = <String>[
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
'https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_16x9/bipbop_16x9_variant.m3u8',
// Add HLS stream with multiple audio tracks if available
Expand Down Expand Up @@ -51,13 +57,25 @@ class _AudioTracksDemoState extends State<AudioTracksDemo> {

await _controller!.initialize();

// Add listener for video player state changes
_controller!.addListener(_onVideoPlayerValueChanged);

// Initialize tracking variables
_wasPlaying = _controller!.value.isPlaying;
_wasInitialized = _controller!.value.isInitialized;

// Get audio tracks after initialization
await _loadAudioTracks();

if (!mounted) {
return;
}
setState(() {
_isLoading = false;
});
} catch (e) {
if (!mounted) {
return;
}
setState(() {
_error = 'Failed to initialize video: $e';
_isLoading = false;
Expand All @@ -72,10 +90,16 @@ class _AudioTracksDemoState extends State<AudioTracksDemo> {

try {
final List<VideoAudioTrack> tracks = await _controller!.getAudioTracks();
if (!mounted) {
return;
}
setState(() {
_audioTracks = tracks;
});
} catch (e) {
if (!mounted) {
return;
}
setState(() {
_error = 'Failed to load audio tracks: $e';
});
Expand All @@ -90,10 +114,6 @@ class _AudioTracksDemoState extends State<AudioTracksDemo> {
try {
await _controller!.selectAudioTrack(trackId);

// Add a small delay to allow ExoPlayer to process the track selection change
// This is needed because ExoPlayer's track selection update is asynchronous
await Future<void>.delayed(const Duration(milliseconds: 100));

// Reload tracks to update selection status
await _loadAudioTracks();

Expand All @@ -113,8 +133,34 @@ class _AudioTracksDemoState extends State<AudioTracksDemo> {
}
}

void _onVideoPlayerValueChanged() {
if (!mounted || _controller == null) {
return;
}

final VideoPlayerValue currentValue = _controller!.value;
bool shouldUpdate = false;

// Check for relevant state changes that affect UI
if (currentValue.isPlaying != _wasPlaying) {
_wasPlaying = currentValue.isPlaying;
shouldUpdate = true;
}

if (currentValue.isInitialized != _wasInitialized) {
_wasInitialized = currentValue.isInitialized;
shouldUpdate = true;
}

// Only call setState if there are relevant changes
if (shouldUpdate) {
setState(() {});
}
}

@override
void dispose() {
_controller?.removeListener(_onVideoPlayerValueChanged);
_controller?.dispose();
super.dispose();
}
Expand All @@ -131,20 +177,21 @@ class _AudioTracksDemoState extends State<AudioTracksDemo> {
// Video selection dropdown
Padding(
padding: const EdgeInsets.all(16.0),
child: DropdownButtonFormField<int>(
value: _selectedVideoIndex,
decoration: const InputDecoration(
labelText: 'Select Video',
child: DropdownMenu<int>(
initialSelection: _selectedVideoIndex,
label: const Text('Select Video'),
inputDecorationTheme: const InputDecorationTheme(
border: OutlineInputBorder(),
),
items:
_sampleVideos.asMap().entries.map((MapEntry<int, String> entry) {
return DropdownMenuItem<int>(
value: entry.key,
child: Text('Video ${entry.key + 1}'),
dropdownMenuEntries:
_sampleVideos.indexed.map((record) {
final (index, _) = record;
return DropdownMenuEntry<int>(
value: index,
label: 'Video ${index + 1}',
);
}).toList(),
onChanged: (int? value) {
onSelected: (int? value) {
if (value != null && value != _selectedVideoIndex) {
setState(() {
_selectedVideoIndex = value;
Expand Down Expand Up @@ -233,7 +280,6 @@ class _AudioTracksDemoState extends State<AudioTracksDemo> {
} else {
_controller!.play();
}
setState(() {});
},
icon: Icon(
_controller!.value.isPlaying ? Icons.pause : Icons.play_arrow,
Expand Down
6 changes: 6 additions & 0 deletions packages/video_player/video_player/lib/video_player.dart
Original file line number Diff line number Diff line change
Expand Up @@ -849,6 +849,12 @@ class VideoPlayerController extends ValueNotifier<VideoPlayerValue> {
throw Exception('VideoPlayerController is disposed or not initialized');
}
await _videoPlayerPlatform.selectAudioTrack(_playerId, trackId);

if (Platform.isAndroid) {
// Add a small delay to allow ExoPlayer to process the track selection change
// This is needed because ExoPlayer's track selection update is asynchronous
await Future<void>.delayed(const Duration(milliseconds: 100));
}
}

bool get _isDisposedOrNotInitialized => _isDisposed || !value.isInitialized;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
## NEXT

* Implements `getAudioTracks()` and `selectAudioTrack()` methods for iOS/macOS using AVFoundation.
* Implements `getAudioTracks()` and `selectAudioTrack()` methods.
* Updates minimum supported SDK version to Flutter 3.29/Dart 3.7.

## 2.8.4
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1155,9 +1155,10 @@ - (void)testGetAudioTracksWithMediaSelectionOptions {
// Mock current selection for both iOS 11+ and older versions
id mockCurrentMediaSelection = OCMClassMock([AVMediaSelection class]);
OCMStub([mockPlayerItem currentMediaSelection]).andReturn(mockCurrentMediaSelection);
OCMStub([mockCurrentMediaSelection selectedMediaOptionInMediaSelectionGroup:mockMediaSelectionGroup])
OCMStub(
[mockCurrentMediaSelection selectedMediaOptionInMediaSelectionGroup:mockMediaSelectionGroup])
.andReturn(mockOption1);

// Also mock the deprecated method for iOS < 11
OCMStub([mockPlayerItem selectedMediaOptionInMediaSelectionGroup:mockMediaSelectionGroup])
.andReturn(mockOption1);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -511,11 +511,12 @@ - (nullable FVPNativeAudioTrackData *)getAudioTracks:(FlutterError *_Nullable *_
}

NSString *commonMetadataTitle = nil;
for (AVMetadataItem *item in option.commonMetadata) {
if ([item.commonKey isEqualToString:AVMetadataCommonKeyTitle] && item.stringValue) {
commonMetadataTitle = item.stringValue;
break;
}
NSArray<AVMetadataItem *> *titleItems =
[AVMetadataItem metadataItemsFromArray:option.commonMetadata
withKey:AVMetadataCommonKeyTitle
keySpace:AVMetadataKeySpaceCommon];
if (titleItems.count > 0 && titleItems.firstObject.stringValue) {
commonMetadataTitle = titleItems.firstObject.stringValue;
}

BOOL isSelected = (currentSelection == option) || [currentSelection isEqual:option];
Expand Down Expand Up @@ -567,59 +568,67 @@ - (nullable FVPNativeAudioTrackData *)getAudioTracks:(FlutterError *_Nullable *_
NSNumber *channelCount = nil;
NSString *codec = nil;

// Only attempt format description parsing in production (non-test) environments
// Skip entirely if we detect any mock objects or test environment indicators
NSString *trackClassName = NSStringFromClass([track class]);
BOOL isTestEnvironment = [trackClassName containsString:@"OCMockObject"] ||
[trackClassName containsString:@"Mock"] ||
NSClassFromString(@"XCTestCase") != nil;

if (track.formatDescriptions.count > 0 && !isTestEnvironment) {
// Attempt format description parsing
if (track.formatDescriptions.count > 0) {
@try {
id formatDescObj = track.formatDescriptions[0];
NSString *className = NSStringFromClass([formatDescObj class]);

// Additional safety: only process objects that are clearly Core Media format descriptions
if (formatDescObj && ([className hasPrefix:@"CMAudioFormatDescription"] ||
[className hasPrefix:@"CMVideoFormatDescription"] ||
[className hasPrefix:@"CMFormatDescription"])) {
CMFormatDescriptionRef formatDesc = (__bridge CMFormatDescriptionRef)formatDescObj;

// Get audio stream basic description
const AudioStreamBasicDescription *audioDesc =
CMAudioFormatDescriptionGetStreamBasicDescription(formatDesc);
if (audioDesc) {
if (audioDesc->mSampleRate > 0) {
sampleRate = @((NSInteger)audioDesc->mSampleRate);
}
if (audioDesc->mChannelsPerFrame > 0) {
channelCount = @(audioDesc->mChannelsPerFrame);
}
}

// Try to get codec information
FourCharCode codecType = CMFormatDescriptionGetMediaSubType(formatDesc);
switch (codecType) {
case kAudioFormatMPEG4AAC:
codec = @"aac";
break;
case kAudioFormatAC3:
codec = @"ac3";
break;
case kAudioFormatEnhancedAC3:
codec = @"eac3";
break;
case kAudioFormatMPEGLayer3:
codec = @"mp3";
break;
default:
codec = nil;
break;
// Validate that we have a valid format description object
if (formatDescObj && [formatDescObj respondsToSelector:@selector(self)]) {
NSString *className = NSStringFromClass([formatDescObj class]);

// Only process objects that are clearly Core Media format descriptions
// This works for both real CMFormatDescription objects and properly configured mock
// objects
if ([className hasPrefix:@"CMAudioFormatDescription"] ||
[className hasPrefix:@"CMVideoFormatDescription"] ||
[className hasPrefix:@"CMFormatDescription"] ||
[formatDescObj
isKindOfClass:[NSObject
class]]) { // Allow mock objects that inherit from NSObject

CMFormatDescriptionRef formatDesc = (__bridge CMFormatDescriptionRef)formatDescObj;

// Validate the format description reference before using Core Media APIs
if (formatDesc && CFGetTypeID(formatDesc) == CMFormatDescriptionGetTypeID()) {
// Get audio stream basic description
const AudioStreamBasicDescription *audioDesc =
CMAudioFormatDescriptionGetStreamBasicDescription(formatDesc);
if (audioDesc) {
if (audioDesc->mSampleRate > 0) {
sampleRate = @((NSInteger)audioDesc->mSampleRate);
}
if (audioDesc->mChannelsPerFrame > 0) {
channelCount = @(audioDesc->mChannelsPerFrame);
}
}

// Try to get codec information
FourCharCode codecType = CMFormatDescriptionGetMediaSubType(formatDesc);
switch (codecType) {
case kAudioFormatMPEG4AAC:
codec = @"aac";
break;
case kAudioFormatAC3:
codec = @"ac3";
break;
case kAudioFormatEnhancedAC3:
codec = @"eac3";
break;
case kAudioFormatMPEGLayer3:
codec = @"mp3";
break;
default:
codec = nil;
break;
}
}
}
}
} @catch (NSException *exception) {
// Silently handle any exceptions from format description parsing
// This can happen with mock objects in tests or invalid format descriptions
// Handle any exceptions from format description parsing gracefully
// This ensures the method continues to work even with mock objects or invalid data
// In tests, this allows the method to return track data with nil format fields
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,7 @@ extension type Descriptor._(JSObject _) implements JSObject {
factory Descriptor.accessor({
void Function(JSAny? value)? set,
JSAny? Function()? get,
}) =>
Descriptor._accessor(set: set?.toJS, get: get?.toJS);
}) => Descriptor._accessor(set: set?.toJS, get: get?.toJS);

external factory Descriptor._accessor({
// JSBoolean configurable,
Expand Down
Loading