Skip to content

[video_player] Add audio track metadata support (bitrate, sample rate, channels, codec) #9782

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

Open
wants to merge 17 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
17 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
4 changes: 4 additions & 0 deletions packages/video_player/video_player/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
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 */,
A3677D96C5C9245FC9DDA03F /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
// 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 {
const AudioTracksDemo({super.key});

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

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

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

Future<void> _initializeVideoPlayer() async {
// Example URL with multiple audio tracks (replace with your test video)
const String videoUrl =
'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4';

Choose a reason for hiding this comment

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

medium

The example video URL points to BigBuckBunny.mp4, which is unlikely to have multiple audio tracks. This makes the demo less effective at showcasing the new audio track selection feature. Consider using a test stream that is known to have multiple audio tracks, such as an HLS stream, to make the example more helpful for developers.

For example, Apple provides test streams that could be used here:

    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(() {});

// Get audio tracks after initialization
await _getAudioTracks();
} catch (e) {
debugPrint('Error initializing video player: $e');
}
}

Future<void> _getAudioTracks() async {
if (_controller == null) return;

setState(() {
_isLoading = true;
});

try {
final tracks = await _controller!.getAudioTracks();
setState(() {
_audioTracks = tracks;
_isLoading = false;
});
} catch (e) {
debugPrint('Error getting audio tracks: $e');
setState(() {
_isLoading = false;
});
}
}

Choose a reason for hiding this comment

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

medium

The _getAudioTracks method can be simplified to improve readability and ensure setState is only called once after the asynchronous operation completes. This also makes it easier to handle the widget's lifecycle by checking mounted once before updating the state.

  Future<void> _getAudioTracks() async {
    if (_controller == null) {
      return;
    }

    setState(() {
      _isLoading = true;
    });

    List<VideoAudioTrack> tracks = <VideoAudioTrack>[];
    try {
      tracks = await _controller!.getAudioTracks();
    } catch (e) {
      debugPrint('Error getting audio tracks: $e');
    }

    if (!mounted) {
      return;
    }

    setState(() {
      _audioTracks = tracks;
      _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(() {
_controller!.value.isPlaying
? _controller!.pause()
: _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: (context, index) {
final 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,
),
);
},
),
),
],
),
),
),
],
),
);
}
}
93 changes: 93 additions & 0 deletions packages/video_player/video_player/example/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ library;

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

void main() {
runApp(
Expand Down Expand Up @@ -295,6 +296,8 @@ class _BumbleBeeRemoteVideo extends StatefulWidget {

class _BumbleBeeRemoteVideoState extends State<_BumbleBeeRemoteVideo> {
late VideoPlayerController _controller;
List<VideoAudioTrack> _audioTracks = [];
bool _isLoadingTracks = false;

Future<ClosedCaptionFile> _loadCaptions() async {
final String fileContents = await DefaultAssetBundle.of(context)
Expand Down Expand Up @@ -349,6 +352,96 @@ 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 audioTracks = await _controller.getAudioTracks();
setState(() {
_audioTracks = audioTracks;
_isLoadingTracks = false;
});
}
},

Choose a reason for hiding this comment

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

high

The _isLoadingTracks flag is never set to true, which means the CircularProgressIndicator in the button's icon is never shown. To provide visual feedback to the user while tracks are being fetched, you should set _isLoadingTracks to true before the asynchronous call and to false after it completes. Using a try...finally block is recommended to ensure the loading state is correctly reset even if an error occurs.

                  onPressed: () async {
                    if (!_controller.value.isInitialized) {
                      return;
                    }
                    setState(() {
                      _isLoadingTracks = true;
                    });
                    try {
                      final List<VideoAudioTrack> audioTracks =
                          await _controller.getAudioTracks();
                      if (mounted) {
                        setState(() {
                          _audioTracks = audioTracks;
                        });
                      }
                    } finally {
                      if (mounted) {
                        setState(() {
                          _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((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(
track.qualityDescription,
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,
),
],
],
),
),
],
),
);
Expand Down
15 changes: 15 additions & 0 deletions packages/video_player/video_player/lib/video_player.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export 'package:video_player_platform_interface/video_player_platform_interface.
show
DataSourceType,
DurationRange,
VideoAudioTrack,
VideoFormat,
VideoPlayerOptions,
VideoPlayerWebOptions,
Expand Down Expand Up @@ -807,6 +808,20 @@ class VideoPlayerController extends ValueNotifier<VideoPlayerValue> {
);
}

/// 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<List<VideoAudioTrack>> getAudioTracks() async {
if (_isDisposedOrNotInitialized) {
return <VideoAudioTrack>[];
}
return _videoPlayerPlatform.getAudioTracks(_playerId);
}

@override
void removeListener(VoidCallback listener) {
// Prevent VideoPlayer from causing an exception to be thrown when attempting to
Expand Down
Loading