Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions packages/video_player/video_player/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
## NEXT

* Adds `getAudioTracks()` and `selectAudioTrack()` methods to retrieve and select available audio tracks.
* Updates minimum supported SDK version to Flutter 3.29/Dart 3.7.

## 2.10.0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
40E43985C26639614BC3B419 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
Expand Down Expand Up @@ -221,6 +222,23 @@
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
40E43985C26639614BC3B419 /* [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;
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,326 @@
// 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';

/// A demo page that showcases audio track functionality.
class AudioTracksDemo extends StatefulWidget {
/// Creates an AudioTracksDemo widget.
const AudioTracksDemo({super.key});

@override
State<AudioTracksDemo> createState() => _AudioTracksDemoState();
}

class _AudioTracksDemoState extends State<AudioTracksDemo> {
VideoPlayerController? _controller;
List<VideoAudioTrack> _audioTracks = <VideoAudioTrack>[];
bool _isLoading = false;
String? _error;

// Sample video URLs with multiple audio tracks
final List<String> _sampleVideos = <String>[
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be possible to use existing videos hosted in the flutter/samples repo (see the other demos), or do they not have tracks? I'm not sure what licenses these samples use.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be a const right?

'https://devstreaming-cdn.apple.com/videos/streaming/examples/bipbop_16x9/bipbop_16x9_variant.m3u8',
// Add HLS stream with multiple audio tracks if available
'https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_fmp4/master.m3u8',
];

int _selectedVideoIndex = 0;

@override
void initState() {
super.initState();
_initializeVideo();
}

Future<void> _initializeVideo() async {
setState(() {
_isLoading = true;
_error = null;
});

try {
await _controller?.dispose();

_controller = VideoPlayerController.networkUrl(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: here and everywhere (especially in _loadAudioTracks): consider using a local var so you don't have to use ! to force unwrap the controller every time.

Uri.parse(_sampleVideos[_selectedVideoIndex]),
);

await _controller!.initialize();

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

setState(() {
_isLoading = false;
});
} catch (e) {
setState(() {
_error = 'Failed to initialize video: $e';
_isLoading = false;
});
}
}

Future<void> _loadAudioTracks() async {
if (_controller == null || !_controller!.value.isInitialized) {
return;
}

try {
final List<VideoAudioTrack> tracks = await _controller!.getAudioTracks();
setState(() {
_audioTracks = tracks;
});
} catch (e) {
setState(() {
_error = 'Failed to load audio tracks: $e';
});
}
}

Future<void> _selectAudioTrack(String trackId) async {
if (_controller == null) {
return;
}

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));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm this could be a major source of flakiness. Shouldn't selectAudioTrack completes the future only after the new tracks becomes ready?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, a major source of user errors if this isn't documented (still I would prefer that we hide the ugliness within our implementation so the user doesn't have to worry about that).


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

if (!mounted) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This check is also needed whenever you want to call setState I think?

return;
}
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Selected audio track: $trackId')));
} catch (e) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to select audio track: $e')),
);
}
}

@override
void dispose() {
_controller?.dispose();
super.dispose();
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Audio Tracks Demo'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
body: Column(
children: <Widget>[
// Video selection dropdown
Padding(
padding: const EdgeInsets.all(16.0),
child: DropdownButtonFormField<int>(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider using https://api.flutter.dev/flutter/material/DropdownMenuFormField-class.html instead, IIRC the Dropdown* widgets are going to be replaced.

value: _selectedVideoIndex,
decoration: const InputDecoration(
labelText: 'Select Video',
border: OutlineInputBorder(),
),
items:
_sampleVideos.asMap().entries.map((MapEntry<int, String> entry) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I'd use https://main-api.flutter.dev/flutter/dart-collection/IterableExtensions/indexed.html instead of asMap(). That would require dart:collection but iirc it's already a transitive dependency of the material library.

return DropdownMenuItem<int>(
value: entry.key,
child: Text('Video ${entry.key + 1}'),
);
}).toList(),
onChanged: (int? value) {
if (value != null && value != _selectedVideoIndex) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I read the material DropdownMenu documentation and it doesn't really tell me when the callback would give me a null value. Does it mean no selection was made?

setState(() {
_selectedVideoIndex = value;
});
_initializeVideo();
}
},
),
),

// Video player
Expanded(
flex: 2,
child: ColoredBox(color: Colors.black, child: _buildVideoPlayer()),
),

// Audio tracks list
Expanded(flex: 3, child: _buildAudioTracksList()),
],
),
floatingActionButton: FloatingActionButton(
onPressed: _loadAudioTracks,
tooltip: 'Refresh Audio Tracks',
child: const Icon(Icons.refresh),
),
);
}

Widget _buildVideoPlayer() {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}

if (_error != null) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: final error case _error?

return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Icon(Icons.error, size: 48, color: Colors.red[300]),
const SizedBox(height: 16),
Text(
_error!,
style: const TextStyle(color: Colors.white),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _initializeVideo,
child: const Text('Retry'),
),
],
),
);
}

if (_controller?.value.isInitialized ?? false) {
return Stack(
alignment: Alignment.center,
children: <Widget>[
AspectRatio(
aspectRatio: _controller!.value.aspectRatio,
child: VideoPlayer(_controller!),
),
_buildPlayPauseButton(),
],
);
}

return const Center(
child: Text('No video loaded', style: TextStyle(color: Colors.white)),
);
}

Widget _buildPlayPauseButton() {
return Container(
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(30),
),
child: IconButton(
iconSize: 48,
color: Colors.white,
onPressed: () {
if (_controller!.value.isPlaying) {
_controller!.pause();
} else {
_controller!.play();
}
setState(() {});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this setState needed here? Can we listen to the controller?

},
icon: Icon(
_controller!.value.isPlaying ? Icons.pause : Icons.play_arrow,
),
),
);
}

Widget _buildAudioTracksList() {
return Container(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
children: <Widget>[
const Icon(Icons.audiotrack),
const SizedBox(width: 8),
Text(
'Audio Tracks (${_audioTracks.length})',
style: Theme.of(context).textTheme.headlineSmall,
),
],
),
const SizedBox(height: 16),

if (_audioTracks.isEmpty)
const Expanded(
child: Center(
child: Text(
'No audio tracks available.\nTry loading a video with multiple audio tracks.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 16, color: Colors.grey),
),
),
)
else
Expanded(
child: ListView.builder(
itemCount: _audioTracks.length,
itemBuilder: (BuildContext context, int index) {
final VideoAudioTrack track = _audioTracks[index];
return _buildAudioTrackTile(track);
},
),
),
],
),
);
}

Widget _buildAudioTrackTile(VideoAudioTrack track) {
return Card(
margin: const EdgeInsets.only(bottom: 8.0),
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.isNotEmpty ? track.label : 'Track ${track.id}',
style: TextStyle(
fontWeight: track.isSelected ? FontWeight.bold : FontWeight.normal,
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text('ID: ${track.id}'),
Text('Language: ${track.language}'),
if (track.codec != null) Text('Codec: ${track.codec}'),
if (track.bitrate != null) Text('Bitrate: ${track.bitrate} bps'),
if (track.sampleRate != null)
Text('Sample Rate: ${track.sampleRate} Hz'),
if (track.channelCount != null)
Text('Channels: ${track.channelCount}'),
],
),
trailing:
track.isSelected
? const Icon(Icons.radio_button_checked, color: Colors.green)
: const Icon(Icons.radio_button_unchecked),
onTap: track.isSelected ? null : () => _selectAudioTrack(track.id),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ListTile is going to look like it's disabled when the track is selected. Is that intended?

),
);
}
}
15 changes: 15 additions & 0 deletions packages/video_player/video_player/example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(home: _App()));
}
Expand All @@ -37,6 +39,19 @@ class _App extends StatelessWidget {
);
},
),
IconButton(
key: const ValueKey<String>('audio_tracks_demo'),
icon: const Icon(Icons.audiotrack),
tooltip: 'Audio Tracks Demo',
onPressed: () {
Navigator.push<AudioTracksDemo>(
context,
MaterialPageRoute<AudioTracksDemo>(
builder: (BuildContext context) => const AudioTracksDemo(),
),
);
},
),
],
bottom: const TabBar(
isScrollable: true,
Expand Down
Loading