-
Notifications
You must be signed in to change notification settings - Fork 3.5k
[video_player] Adds audio track metadata fetching and audio track selection feature #9925
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
c8b0071
4e4dc8c
25de26c
31f9030
8f711b5
e0f6d65
fc30013
8a68e76
894f516
644e08e
fdde6f8
4291609
8dfd8e3
a892a5e
1537778
c222584
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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', | ||
'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( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: here and everywhere (especially in |
||
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)); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm this could be a major source of flakiness. Shouldn't There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This check is also needed whenever you want to call |
||
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>( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
return DropdownMenuItem<int>( | ||
value: entry.key, | ||
child: Text('Video ${entry.key + 1}'), | ||
); | ||
}).toList(), | ||
onChanged: (int? value) { | ||
if (value != null && value != _selectedVideoIndex) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
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) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: |
||
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(() {}); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? |
||
), | ||
); | ||
} | ||
} |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?