diff --git a/packages/video_player/video_player_android/CHANGELOG.md b/packages/video_player/video_player_android/CHANGELOG.md
index c05ef82afe1..2e825291827 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
+
+* Implements `getAudioTracks()` and `selectAudioTrack()` methods for Android using ExoPlayer.
+
## 2.8.22
* Bumps kotlin_version to 2.2.21.
diff --git a/packages/video_player/video_player_android/android/build.gradle b/packages/video_player/video_player_android/android/build.gradle
index 517f730e9c3..0530bf7b493 100644
--- a/packages/video_player/video_player_android/android/build.gradle
+++ b/packages/video_player/video_player_android/android/build.gradle
@@ -32,10 +32,11 @@ android {
minSdkVersion 24
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
- lintOptions {
+ lint {
checkAllWarnings = true
warningsAsErrors = true
disable 'AndroidGradlePluginVersion', 'InvalidPackage', 'GradleDependency', 'NewerVersionAvailable'
+ baseline = file("lint-baseline.xml")
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
diff --git a/packages/video_player/video_player_android/android/lint-baseline.xml b/packages/video_player/video_player_android/android/lint-baseline.xml
new file mode 100644
index 00000000000..f13a33607f6
--- /dev/null
+++ b/packages/video_player/video_player_android/android/lint-baseline.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
diff --git a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/ExoPlayerEventListener.java b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/ExoPlayerEventListener.java
index 5b5203b39e7..33988786a78 100644
--- a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/ExoPlayerEventListener.java
+++ b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/ExoPlayerEventListener.java
@@ -5,8 +5,11 @@
package io.flutter.plugins.videoplayer;
import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.media3.common.C;
import androidx.media3.common.PlaybackException;
import androidx.media3.common.Player;
+import androidx.media3.common.Tracks;
import androidx.media3.exoplayer.ExoPlayer;
public abstract class ExoPlayerEventListener implements Player.Listener {
@@ -88,4 +91,34 @@ public void onPlayerError(@NonNull final PlaybackException error) {
public void onIsPlayingChanged(boolean isPlaying) {
events.onIsPlayingStateUpdate(isPlaying);
}
+
+ @Override
+ public void onTracksChanged(@NonNull Tracks tracks) {
+ // Find the currently selected audio track and notify
+ String selectedTrackId = findSelectedAudioTrackId(tracks);
+ events.onAudioTrackChanged(selectedTrackId);
+ }
+
+ /**
+ * Finds the ID of the currently selected audio track.
+ *
+ * @param tracks The current tracks
+ * @return The track ID in format "groupIndex_trackIndex", or null if no audio track is selected
+ */
+ @Nullable
+ private String findSelectedAudioTrackId(@NonNull Tracks tracks) {
+ int groupIndex = 0;
+ for (Tracks.Group group : tracks.getGroups()) {
+ if (group.getType() == C.TRACK_TYPE_AUDIO && group.isSelected()) {
+ // Find the selected track within this group
+ for (int i = 0; i < group.length; i++) {
+ if (group.isTrackSelected(i)) {
+ return groupIndex + "_" + i;
+ }
+ }
+ }
+ groupIndex++;
+ }
+ return 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 171b166d1cc..7cfb5c1c13b 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,18 @@
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.TrackGroup;
+import androidx.media3.common.TrackSelectionOverride;
+import androidx.media3.common.Tracks;
+import androidx.media3.common.util.UnstableApi;
import androidx.media3.exoplayer.ExoPlayer;
+import androidx.media3.exoplayer.trackselection.DefaultTrackSelector;
import io.flutter.view.TextureRegistry.SurfaceProducer;
+import java.util.ArrayList;
+import java.util.List;
/**
* A class responsible for managing video playback using {@link ExoPlayer}.
@@ -26,6 +34,8 @@ public abstract class VideoPlayer implements VideoPlayerInstanceApi {
@Nullable protected final SurfaceProducer surfaceProducer;
@Nullable private DisposeHandler disposeHandler;
@NonNull protected ExoPlayer exoPlayer;
+ // TODO: Migrate to stable API, see https://github.com/flutter/flutter/issues/147039.
+ @UnstableApi @Nullable protected DefaultTrackSelector trackSelector;
/** A closure-compatible signature since {@link java.util.function.Supplier} is API level 24. */
public interface ExoPlayerProvider {
@@ -43,6 +53,8 @@ public interface DisposeHandler {
void onDispose();
}
+ // TODO: Migrate to stable API, see https://github.com/flutter/flutter/issues/147039.
+ @UnstableApi
// Error thrown for this-escape warning on JDK 21+ due to https://bugs.openjdk.org/browse/JDK-8015831.
// Keeping behavior as-is and addressing the warning could cause a regression: https://github.com/flutter/packages/pull/10193
@SuppressWarnings("this-escape")
@@ -55,6 +67,12 @@ public VideoPlayer(
this.videoPlayerEvents = events;
this.surfaceProducer = surfaceProducer;
exoPlayer = exoPlayerProvider.get();
+
+ // Try to get the track selector from the ExoPlayer if it was built with one
+ if (exoPlayer.getTrackSelector() instanceof DefaultTrackSelector) {
+ trackSelector = (DefaultTrackSelector) exoPlayer.getTrackSelector();
+ }
+
exoPlayer.setMediaItem(mediaItem);
exoPlayer.prepare();
exoPlayer.addListener(createExoPlayerEventListener(exoPlayer, surfaceProducer));
@@ -125,6 +143,96 @@ public ExoPlayer getExoPlayer() {
return exoPlayer;
}
+ // TODO: Migrate to stable API, see https://github.com/flutter/flutter/issues/147039.
+ @UnstableApi
+ @Override
+ public @NonNull NativeAudioTrackData 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 audio track data with metadata
+ ExoPlayerAudioTrackData audioTrack =
+ new ExoPlayerAudioTrackData(
+ (long) groupIndex,
+ (long) trackIndex,
+ format.label,
+ format.language,
+ isSelected,
+ format.bitrate != Format.NO_VALUE ? (long) format.bitrate : null,
+ format.sampleRate != Format.NO_VALUE ? (long) format.sampleRate : null,
+ format.channelCount != Format.NO_VALUE ? (long) format.channelCount : null,
+ format.codecs != null ? format.codecs : null);
+
+ audioTracks.add(audioTrack);
+ }
+ }
+ }
+ return new NativeAudioTrackData(audioTracks);
+ }
+
+ // TODO: Migrate to stable API, see https://github.com/flutter/flutter/issues/147039.
+ @UnstableApi
+ @Override
+ public void selectAudioTrack(long groupIndex, long trackIndex) {
+ if (trackSelector == null) {
+ throw new IllegalStateException("Cannot select audio track: track selector is null");
+ }
+
+ // Get current tracks
+ Tracks tracks = exoPlayer.getCurrentTracks();
+
+ if (groupIndex < 0 || groupIndex >= tracks.getGroups().size()) {
+ throw new IllegalArgumentException(
+ "Cannot select audio track: groupIndex "
+ + groupIndex
+ + " is out of bounds (available groups: "
+ + tracks.getGroups().size()
+ + ")");
+ }
+
+ Tracks.Group group = tracks.getGroups().get((int) groupIndex);
+
+ // Verify it's an audio track
+ if (group.getType() != C.TRACK_TYPE_AUDIO) {
+ throw new IllegalArgumentException(
+ "Cannot select audio track: group at index "
+ + groupIndex
+ + " is not an audio track (type: "
+ + group.getType()
+ + ")");
+ }
+
+ // Verify the track index is valid
+ if (trackIndex < 0 || (int) trackIndex >= group.length) {
+ throw new IllegalArgumentException(
+ "Cannot select audio track: trackIndex "
+ + trackIndex
+ + " is out of bounds (available tracks in group: "
+ + group.length
+ + ")");
+ }
+
+ // Get the track group and create a selection override
+ TrackGroup trackGroup = group.getMediaTrackGroup();
+ TrackSelectionOverride override = new TrackSelectionOverride(trackGroup, (int) trackIndex);
+
+ // Apply the track selection override
+ trackSelector.setParameters(
+ trackSelector.buildUponParameters().setOverrideForType(override).build());
+ }
+
public void dispose() {
if (disposeHandler != null) {
disposeHandler.onDispose();
diff --git a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerCallbacks.java b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerCallbacks.java
index 379f73e2091..4cac902319e 100644
--- a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerCallbacks.java
+++ b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerCallbacks.java
@@ -24,4 +24,6 @@ public interface VideoPlayerCallbacks {
void onError(@NonNull String code, @Nullable String message, @Nullable Object details);
void onIsPlayingStateUpdate(boolean isPlaying);
+
+ void onAudioTrackChanged(@Nullable String selectedTrackId);
}
diff --git a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerEventCallbacks.java b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerEventCallbacks.java
index 782f1cc2ce8..a471ec960e6 100644
--- a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerEventCallbacks.java
+++ b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerEventCallbacks.java
@@ -63,4 +63,9 @@ public void onError(@NonNull String code, @Nullable String message, @Nullable Ob
public void onIsPlayingStateUpdate(boolean isPlaying) {
eventSink.success(new IsPlayingStateEvent(isPlaying));
}
+
+ @Override
+ public void onAudioTrackChanged(@Nullable String selectedTrackId) {
+ eventSink.success(new AudioTrackChangedEvent(selectedTrackId));
+ }
}
diff --git a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerPlugin.java b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerPlugin.java
index e3a8b4dc1d6..49adaf4b7b3 100644
--- a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerPlugin.java
+++ b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerPlugin.java
@@ -8,6 +8,8 @@
import android.util.LongSparseArray;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
+import androidx.annotation.OptIn;
+import androidx.media3.common.util.UnstableApi;
import io.flutter.FlutterInjector;
import io.flutter.Log;
import io.flutter.embedding.engine.plugins.FlutterPlugin;
@@ -78,6 +80,7 @@ public void initialize() {
disposeAllPlayers();
}
+ @OptIn(markerClass = UnstableApi.class)
@Override
public long createForPlatformView(@NonNull CreationOptions options) {
final VideoAsset videoAsset = videoAssetWithOptions(options);
@@ -95,6 +98,7 @@ public long createForPlatformView(@NonNull CreationOptions options) {
return id;
}
+ @OptIn(markerClass = UnstableApi.class)
@Override
public @NonNull TexturePlayerIds createForTextureView(@NonNull CreationOptions options) {
final VideoAsset videoAsset = videoAssetWithOptions(options);
diff --git a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/platformview/PlatformViewVideoPlayer.java b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/platformview/PlatformViewVideoPlayer.java
index 34b7533bd38..a7c079773b5 100644
--- a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/platformview/PlatformViewVideoPlayer.java
+++ b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/platformview/PlatformViewVideoPlayer.java
@@ -9,6 +9,7 @@
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import androidx.media3.common.MediaItem;
+import androidx.media3.common.util.UnstableApi;
import androidx.media3.exoplayer.ExoPlayer;
import io.flutter.plugins.videoplayer.ExoPlayerEventListener;
import io.flutter.plugins.videoplayer.VideoAsset;
@@ -22,6 +23,8 @@
* displaying the video in the app.
*/
public class PlatformViewVideoPlayer extends VideoPlayer {
+ // TODO: Migrate to stable API, see https://github.com/flutter/flutter/issues/147039.
+ @UnstableApi
@VisibleForTesting
public PlatformViewVideoPlayer(
@NonNull VideoPlayerCallbacks events,
@@ -40,6 +43,8 @@ public PlatformViewVideoPlayer(
* @param options options for playback.
* @return a video player instance.
*/
+ // TODO: Migrate to stable API, see https://github.com/flutter/flutter/issues/147039.
+ @UnstableApi
@NonNull
public static PlatformViewVideoPlayer create(
@NonNull Context context,
@@ -51,8 +56,11 @@ public static PlatformViewVideoPlayer create(
asset.getMediaItem(),
options,
() -> {
+ androidx.media3.exoplayer.trackselection.DefaultTrackSelector trackSelector =
+ new androidx.media3.exoplayer.trackselection.DefaultTrackSelector(context);
ExoPlayer.Builder builder =
new ExoPlayer.Builder(context)
+ .setTrackSelector(trackSelector)
.setMediaSourceFactory(asset.getMediaSourceFactory(context));
return builder.build();
});
diff --git a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/texture/TextureVideoPlayer.java b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/texture/TextureVideoPlayer.java
index 57ed030f564..e482bdd8502 100644
--- a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/texture/TextureVideoPlayer.java
+++ b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/texture/TextureVideoPlayer.java
@@ -11,6 +11,7 @@
import androidx.annotation.RestrictTo;
import androidx.annotation.VisibleForTesting;
import androidx.media3.common.MediaItem;
+import androidx.media3.common.util.UnstableApi;
import androidx.media3.exoplayer.ExoPlayer;
import io.flutter.plugins.videoplayer.ExoPlayerEventListener;
import io.flutter.plugins.videoplayer.VideoAsset;
@@ -39,6 +40,8 @@ public final class TextureVideoPlayer extends VideoPlayer implements SurfaceProd
* @param options options for playback.
* @return a video player instance.
*/
+ // TODO: Migrate to stable API, see https://github.com/flutter/flutter/issues/147039.
+ @UnstableApi
@NonNull
public static TextureVideoPlayer create(
@NonNull Context context,
@@ -52,13 +55,18 @@ public static TextureVideoPlayer create(
asset.getMediaItem(),
options,
() -> {
+ androidx.media3.exoplayer.trackselection.DefaultTrackSelector trackSelector =
+ new androidx.media3.exoplayer.trackselection.DefaultTrackSelector(context);
ExoPlayer.Builder builder =
new ExoPlayer.Builder(context)
+ .setTrackSelector(trackSelector)
.setMediaSourceFactory(asset.getMediaSourceFactory(context));
return builder.build();
});
}
+ // TODO: Migrate to stable API, see https://github.com/flutter/flutter/issues/147039.
+ @UnstableApi
@VisibleForTesting
public TextureVideoPlayer(
@NonNull VideoPlayerCallbacks events,
diff --git a/packages/video_player/video_player_android/android/src/main/kotlin/io/flutter/plugins/videoplayer/Messages.kt b/packages/video_player/video_player_android/android/src/main/kotlin/io/flutter/plugins/videoplayer/Messages.kt
index 052cbf5232e..7a31a8609b3 100644
--- a/packages/video_player/video_player_android/android/src/main/kotlin/io/flutter/plugins/videoplayer/Messages.kt
+++ b/packages/video_player/video_player_android/android/src/main/kotlin/io/flutter/plugins/videoplayer/Messages.kt
@@ -55,7 +55,7 @@ private object MessagesPigeonUtils {
}
if (a is Map<*, *> && b is Map<*, *>) {
return a.size == b.size &&
- a.all { (b as Map).contains(it.key) && deepEquals(it.value, b[it.key]) }
+ a.all { (b as Map).containsKey(it.key) && deepEquals(it.value, b[it.key]) }
}
return a == b
}
@@ -225,6 +225,44 @@ data class IsPlayingStateEvent(val isPlaying: Boolean) : PlatformVideoEvent() {
override fun hashCode(): Int = toList().hashCode()
}
+/**
+ * Sent when audio tracks change.
+ *
+ * This includes when the selected audio track changes after calling selectAudioTrack. Corresponds
+ * to ExoPlayer's onTracksChanged.
+ *
+ * Generated class from Pigeon that represents data sent in messages.
+ */
+data class AudioTrackChangedEvent(
+ /** The ID of the newly selected audio track, if any. */
+ val selectedTrackId: String? = null
+) : PlatformVideoEvent() {
+ companion object {
+ fun fromList(pigeonVar_list: List): AudioTrackChangedEvent {
+ val selectedTrackId = pigeonVar_list[0] as String?
+ return AudioTrackChangedEvent(selectedTrackId)
+ }
+ }
+
+ fun toList(): List {
+ return listOf(
+ selectedTrackId,
+ )
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (other !is AudioTrackChangedEvent) {
+ return false
+ }
+ if (this === other) {
+ return true
+ }
+ return MessagesPigeonUtils.deepEquals(toList(), other.toList())
+ }
+
+ override fun hashCode(): Int = toList().hashCode()
+}
+
/**
* Information passed to the platform view creation.
*
@@ -326,6 +364,199 @@ data class TexturePlayerIds(val playerId: Long, val textureId: Long) {
override fun hashCode(): Int = toList().hashCode()
}
+/** Generated class from Pigeon that represents data sent in messages. */
+data class PlaybackState(
+ /** The current playback position, in milliseconds. */
+ val playPosition: Long,
+ /** The current buffer position, in milliseconds. */
+ val bufferPosition: Long
+) {
+ companion object {
+ fun fromList(pigeonVar_list: List): PlaybackState {
+ val playPosition = pigeonVar_list[0] as Long
+ val bufferPosition = pigeonVar_list[1] as Long
+ return PlaybackState(playPosition, bufferPosition)
+ }
+ }
+
+ fun toList(): List {
+ return listOf(
+ playPosition,
+ bufferPosition,
+ )
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (other !is PlaybackState) {
+ return false
+ }
+ if (this === other) {
+ return true
+ }
+ return MessagesPigeonUtils.deepEquals(toList(), other.toList())
+ }
+
+ override fun hashCode(): Int = toList().hashCode()
+}
+
+/**
+ * Represents an audio track in a video.
+ *
+ * Generated class from Pigeon that represents data sent in messages.
+ */
+data class AudioTrackMessage(
+ val id: String,
+ val label: String,
+ val language: String,
+ val isSelected: Boolean,
+ val bitrate: Long? = null,
+ val sampleRate: Long? = null,
+ val channelCount: Long? = null,
+ val codec: String? = null
+) {
+ companion object {
+ fun fromList(pigeonVar_list: List): AudioTrackMessage {
+ val id = pigeonVar_list[0] as String
+ val label = pigeonVar_list[1] as String
+ val language = pigeonVar_list[2] as String
+ val isSelected = pigeonVar_list[3] as Boolean
+ val bitrate = pigeonVar_list[4] as Long?
+ val sampleRate = pigeonVar_list[5] as Long?
+ val channelCount = pigeonVar_list[6] as Long?
+ val codec = pigeonVar_list[7] as String?
+ return AudioTrackMessage(
+ id, label, language, isSelected, bitrate, sampleRate, channelCount, codec)
+ }
+ }
+
+ fun toList(): List {
+ return listOf(
+ id,
+ label,
+ language,
+ isSelected,
+ bitrate,
+ sampleRate,
+ channelCount,
+ codec,
+ )
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (other !is AudioTrackMessage) {
+ return false
+ }
+ if (this === other) {
+ return true
+ }
+ return MessagesPigeonUtils.deepEquals(toList(), other.toList())
+ }
+
+ override fun hashCode(): Int = toList().hashCode()
+}
+
+/**
+ * Raw audio track data from ExoPlayer Format objects.
+ *
+ * Generated class from Pigeon that represents data sent in messages.
+ */
+data class ExoPlayerAudioTrackData(
+ val groupIndex: Long,
+ val trackIndex: Long,
+ val label: String? = null,
+ val language: String? = null,
+ val isSelected: Boolean,
+ val bitrate: Long? = null,
+ val sampleRate: Long? = null,
+ val channelCount: Long? = null,
+ val codec: String? = null
+) {
+ companion object {
+ fun fromList(pigeonVar_list: List): ExoPlayerAudioTrackData {
+ val groupIndex = pigeonVar_list[0] as Long
+ val trackIndex = pigeonVar_list[1] as Long
+ val label = pigeonVar_list[2] as String?
+ val language = pigeonVar_list[3] as String?
+ val isSelected = pigeonVar_list[4] as Boolean
+ val bitrate = pigeonVar_list[5] as Long?
+ val sampleRate = pigeonVar_list[6] as Long?
+ val channelCount = pigeonVar_list[7] as Long?
+ val codec = pigeonVar_list[8] as String?
+ return ExoPlayerAudioTrackData(
+ groupIndex,
+ trackIndex,
+ label,
+ language,
+ isSelected,
+ bitrate,
+ sampleRate,
+ channelCount,
+ codec)
+ }
+ }
+
+ fun toList(): List {
+ return listOf(
+ groupIndex,
+ trackIndex,
+ label,
+ language,
+ isSelected,
+ bitrate,
+ sampleRate,
+ channelCount,
+ codec,
+ )
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (other !is ExoPlayerAudioTrackData) {
+ return false
+ }
+ if (this === other) {
+ return true
+ }
+ return MessagesPigeonUtils.deepEquals(toList(), other.toList())
+ }
+
+ override fun hashCode(): Int = toList().hashCode()
+}
+
+/**
+ * Container for raw audio track data from Android ExoPlayer.
+ *
+ * Generated class from Pigeon that represents data sent in messages.
+ */
+data class NativeAudioTrackData(
+ /** ExoPlayer-based tracks */
+ val exoPlayerTracks: List? = null
+) {
+ companion object {
+ fun fromList(pigeonVar_list: List): NativeAudioTrackData {
+ val exoPlayerTracks = pigeonVar_list[0] as List?
+ return NativeAudioTrackData(exoPlayerTracks)
+ }
+ }
+
+ fun toList(): List {
+ return listOf(
+ exoPlayerTracks,
+ )
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (other !is NativeAudioTrackData) {
+ return false
+ }
+ if (this === other) {
+ return true
+ }
+ return MessagesPigeonUtils.deepEquals(toList(), other.toList())
+ }
+
+ override fun hashCode(): Int = toList().hashCode()
+}
+
private open class MessagesPigeonCodec : StandardMessageCodec() {
override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? {
return when (type) {
@@ -345,16 +576,31 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
return (readValue(buffer) as? List)?.let { IsPlayingStateEvent.fromList(it) }
}
134.toByte() -> {
+ return (readValue(buffer) as? List)?.let { AudioTrackChangedEvent.fromList(it) }
+ }
+ 135.toByte() -> {
return (readValue(buffer) as? List)?.let {
PlatformVideoViewCreationParams.fromList(it)
}
}
- 135.toByte() -> {
+ 136.toByte() -> {
return (readValue(buffer) as? List)?.let { CreationOptions.fromList(it) }
}
- 136.toByte() -> {
+ 137.toByte() -> {
return (readValue(buffer) as? List)?.let { TexturePlayerIds.fromList(it) }
}
+ 138.toByte() -> {
+ return (readValue(buffer) as? List)?.let { PlaybackState.fromList(it) }
+ }
+ 139.toByte() -> {
+ return (readValue(buffer) as? List)?.let { AudioTrackMessage.fromList(it) }
+ }
+ 140.toByte() -> {
+ return (readValue(buffer) as? List)?.let { ExoPlayerAudioTrackData.fromList(it) }
+ }
+ 141.toByte() -> {
+ return (readValue(buffer) as? List)?.let { NativeAudioTrackData.fromList(it) }
+ }
else -> super.readValueOfType(type, buffer)
}
}
@@ -381,18 +627,38 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
stream.write(133)
writeValue(stream, value.toList())
}
- is PlatformVideoViewCreationParams -> {
+ is AudioTrackChangedEvent -> {
stream.write(134)
writeValue(stream, value.toList())
}
- is CreationOptions -> {
+ is PlatformVideoViewCreationParams -> {
stream.write(135)
writeValue(stream, value.toList())
}
- is TexturePlayerIds -> {
+ is CreationOptions -> {
stream.write(136)
writeValue(stream, value.toList())
}
+ is TexturePlayerIds -> {
+ stream.write(137)
+ writeValue(stream, value.toList())
+ }
+ is PlaybackState -> {
+ stream.write(138)
+ writeValue(stream, value.toList())
+ }
+ is AudioTrackMessage -> {
+ stream.write(139)
+ writeValue(stream, value.toList())
+ }
+ is ExoPlayerAudioTrackData -> {
+ stream.write(140)
+ writeValue(stream, value.toList())
+ }
+ is NativeAudioTrackData -> {
+ stream.write(141)
+ writeValue(stream, value.toList())
+ }
else -> super.writeValue(stream, value)
}
}
@@ -584,6 +850,10 @@ interface VideoPlayerInstanceApi {
fun getCurrentPosition(): Long
/** Returns the current buffer position, in milliseconds. */
fun getBufferedPosition(): Long
+ /** Gets the available audio tracks for the video. */
+ fun getAudioTracks(): NativeAudioTrackData
+ /** Selects which audio track is chosen for playback from its [groupIndex] and [trackIndex] */
+ fun selectAudioTrack(groupIndex: Long, trackIndex: Long)
companion object {
/** The codec used by VideoPlayerInstanceApi. */
@@ -774,6 +1044,50 @@ interface VideoPlayerInstanceApi {
channel.setMessageHandler(null)
}
}
+ run {
+ val channel =
+ BasicMessageChannel(
+ binaryMessenger,
+ "dev.flutter.pigeon.video_player_android.VideoPlayerInstanceApi.getAudioTracks$separatedMessageChannelSuffix",
+ codec)
+ if (api != null) {
+ channel.setMessageHandler { _, reply ->
+ val wrapped: List =
+ try {
+ listOf(api.getAudioTracks())
+ } catch (exception: Throwable) {
+ MessagesPigeonUtils.wrapError(exception)
+ }
+ reply.reply(wrapped)
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
+ run {
+ val channel =
+ BasicMessageChannel(
+ binaryMessenger,
+ "dev.flutter.pigeon.video_player_android.VideoPlayerInstanceApi.selectAudioTrack$separatedMessageChannelSuffix",
+ codec)
+ if (api != null) {
+ channel.setMessageHandler { message, reply ->
+ val args = message as List
+ val groupIndexArg = args[0] as Long
+ val trackIndexArg = args[1] as Long
+ val wrapped: List =
+ try {
+ api.selectAudioTrack(groupIndexArg, trackIndexArg)
+ listOf(null)
+ } catch (exception: Throwable) {
+ MessagesPigeonUtils.wrapError(exception)
+ }
+ reply.reply(wrapped)
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
}
}
}
diff --git a/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoPlayerTest.java b/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoPlayerTest.java
index 9876f1245d6..92c2ff5f156 100644
--- a/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoPlayerTest.java
+++ b/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoPlayerTest.java
@@ -5,6 +5,12 @@
package io.flutter.plugins.videoplayer;
import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.*;
import androidx.annotation.NonNull;
@@ -15,9 +21,16 @@
import androidx.media3.common.MediaItem;
import androidx.media3.common.PlaybackParameters;
import androidx.media3.common.Player;
+import androidx.media3.common.TrackGroup;
+import androidx.media3.common.TrackSelectionOverride;
+import androidx.media3.common.Tracks;
import androidx.media3.exoplayer.ExoPlayer;
+import androidx.media3.exoplayer.trackselection.DefaultTrackSelector;
+import com.google.common.collect.ImmutableList;
import io.flutter.plugins.videoplayer.platformview.PlatformViewExoPlayerEventListener;
import io.flutter.view.TextureRegistry.SurfaceProducer;
+import java.lang.reflect.Field;
+import java.util.List;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
@@ -227,4 +240,384 @@ public void disposeReleasesExoPlayer() {
verify(mockExoPlayer).release();
}
+
+ // Helper method to set the length field on a mocked Tracks.Group
+ private void setGroupLength(Tracks.Group group, int length) {
+ try {
+ Field lengthField = group.getClass().getDeclaredField("length");
+ lengthField.setAccessible(true);
+ lengthField.setInt(group, length);
+ } catch (Exception e) {
+ throw new RuntimeException("Failed to set length field", e);
+ }
+ }
+
+ @Test
+ public void testGetAudioTracks_withMultipleAudioTracks() {
+ Tracks mockTracks = mock(Tracks.class);
+ Tracks.Group mockAudioGroup1 = mock(Tracks.Group.class);
+ Tracks.Group mockAudioGroup2 = mock(Tracks.Group.class);
+ Tracks.Group mockVideoGroup = mock(Tracks.Group.class);
+
+ // Create mock formats for audio tracks
+ Format audioFormat1 =
+ new Format.Builder()
+ .setId("audio_track_1")
+ .setLabel("English")
+ .setLanguage("en")
+ .setAverageBitrate(128000)
+ .setSampleRate(48000)
+ .setChannelCount(2)
+ .setCodecs("mp4a.40.2")
+ .build();
+
+ Format audioFormat2 =
+ new Format.Builder()
+ .setId("audio_track_2")
+ .setLabel("Español")
+ .setLanguage("es")
+ .setAverageBitrate(96000)
+ .setSampleRate(44100)
+ .setChannelCount(2)
+ .setCodecs("mp4a.40.2")
+ .build();
+
+ // Mock audio groups and set length field
+ setGroupLength(mockAudioGroup1, 1);
+ setGroupLength(mockAudioGroup2, 1);
+
+ when(mockAudioGroup1.getType()).thenReturn(C.TRACK_TYPE_AUDIO);
+ when(mockAudioGroup1.getTrackFormat(0)).thenReturn(audioFormat1);
+ when(mockAudioGroup1.isTrackSelected(0)).thenReturn(true);
+
+ when(mockAudioGroup2.getType()).thenReturn(C.TRACK_TYPE_AUDIO);
+ when(mockAudioGroup2.getTrackFormat(0)).thenReturn(audioFormat2);
+ when(mockAudioGroup2.isTrackSelected(0)).thenReturn(false);
+
+ when(mockVideoGroup.getType()).thenReturn(C.TRACK_TYPE_VIDEO);
+
+ // Mock tracks
+ ImmutableList groups =
+ ImmutableList.of(mockAudioGroup1, mockAudioGroup2, mockVideoGroup);
+ when(mockTracks.getGroups()).thenReturn(groups);
+ when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks);
+
+ VideoPlayer videoPlayer = createVideoPlayer();
+
+ // Test the method
+ NativeAudioTrackData nativeData = videoPlayer.getAudioTracks();
+ List result = nativeData.getExoPlayerTracks();
+
+ // Verify results
+ assertNotNull(result);
+ assertEquals(2, result.size());
+
+ // Verify first track
+ ExoPlayerAudioTrackData track1 = result.get(0);
+ assertEquals(0L, track1.getGroupIndex());
+ assertEquals(0L, track1.getTrackIndex());
+ assertEquals("English", track1.getLabel());
+ assertEquals("en", track1.getLanguage());
+ assertTrue(track1.isSelected());
+ 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
+ ExoPlayerAudioTrackData track2 = result.get(1);
+ assertEquals(1L, track2.getGroupIndex());
+ assertEquals(0L, track2.getTrackIndex());
+ assertEquals("Español", track2.getLabel());
+ assertEquals("es", track2.getLanguage());
+ assertFalse(track2.isSelected());
+ assertEquals(Long.valueOf(96000), track2.getBitrate());
+ assertEquals(Long.valueOf(44100), track2.getSampleRate());
+ assertEquals(Long.valueOf(2), track2.getChannelCount());
+ assertEquals("mp4a.40.2", track2.getCodec());
+
+ videoPlayer.dispose();
+ }
+
+ @Test
+ public void testGetAudioTracks_withNoAudioTracks() {
+ Tracks mockTracks = mock(Tracks.class);
+ Tracks.Group mockVideoGroup = mock(Tracks.Group.class);
+
+ // Mock video group only (no audio tracks)
+ when(mockVideoGroup.getType()).thenReturn(C.TRACK_TYPE_VIDEO);
+
+ ImmutableList groups = ImmutableList.of(mockVideoGroup);
+ when(mockTracks.getGroups()).thenReturn(groups);
+ when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks);
+
+ VideoPlayer videoPlayer = createVideoPlayer();
+
+ // Test the method
+ NativeAudioTrackData nativeData = videoPlayer.getAudioTracks();
+ List result = nativeData.getExoPlayerTracks();
+
+ // Verify results
+ assertNotNull(result);
+ assertEquals(0, result.size());
+
+ videoPlayer.dispose();
+ }
+
+ @Test
+ public void testGetAudioTracks_withNullValues() {
+ Tracks mockTracks = mock(Tracks.class);
+ Tracks.Group mockAudioGroup1 = mock(Tracks.Group.class);
+
+ // Create format with null/missing values
+ Format audioFormat =
+ new Format.Builder()
+ .setId("audio_track_null")
+ .setLabel(null) // Null label
+ .setLanguage(null) // Null language
+ .setAverageBitrate(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 and set length field
+ setGroupLength(mockAudioGroup1, 1);
+ when(mockAudioGroup1.getType()).thenReturn(C.TRACK_TYPE_AUDIO);
+ when(mockAudioGroup1.getTrackFormat(0)).thenReturn(audioFormat);
+ when(mockAudioGroup1.isTrackSelected(0)).thenReturn(false);
+
+ ImmutableList groups = ImmutableList.of(mockAudioGroup1);
+ when(mockTracks.getGroups()).thenReturn(groups);
+ when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks);
+
+ VideoPlayer videoPlayer = createVideoPlayer();
+
+ // Test the method
+ NativeAudioTrackData nativeData = videoPlayer.getAudioTracks();
+ List result = nativeData.getExoPlayerTracks();
+
+ // Verify results
+ assertNotNull(result);
+ assertEquals(1, result.size());
+
+ ExoPlayerAudioTrackData track = result.get(0);
+ assertEquals(0L, track.getGroupIndex());
+ assertEquals(0L, track.getTrackIndex());
+ assertNull(track.getLabel()); // Null values should be preserved
+ assertNull(track.getLanguage()); // Null values should be preserved
+ assertFalse(track.isSelected());
+ assertNull(track.getBitrate());
+ assertNull(track.getSampleRate());
+ assertNull(track.getChannelCount());
+ assertNull(track.getCodec());
+
+ videoPlayer.dispose();
+ }
+
+ @Test
+ public void testGetAudioTracks_withMultipleTracksInSameGroup() {
+ Tracks mockTracks = mock(Tracks.class);
+ Tracks.Group mockAudioGroup1 = mock(Tracks.Group.class);
+
+ // Create format for group with multiple tracks
+ Format audioFormat1 =
+ new Format.Builder()
+ .setId("audio_track_1")
+ .setLabel("Track 1")
+ .setLanguage("en")
+ .setAverageBitrate(128000)
+ .build();
+
+ Format audioFormat2 =
+ new Format.Builder()
+ .setId("audio_track_2")
+ .setLabel("Track 2")
+ .setLanguage("en")
+ .setAverageBitrate(192000)
+ .build();
+
+ // Mock audio group with multiple tracks
+ setGroupLength(mockAudioGroup1, 2);
+ when(mockAudioGroup1.getType()).thenReturn(C.TRACK_TYPE_AUDIO);
+ when(mockAudioGroup1.getTrackFormat(0)).thenReturn(audioFormat1);
+ when(mockAudioGroup1.getTrackFormat(1)).thenReturn(audioFormat2);
+ when(mockAudioGroup1.isTrackSelected(0)).thenReturn(true);
+ when(mockAudioGroup1.isTrackSelected(1)).thenReturn(false);
+
+ ImmutableList groups = ImmutableList.of(mockAudioGroup1);
+ when(mockTracks.getGroups()).thenReturn(groups);
+ when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks);
+
+ VideoPlayer videoPlayer = createVideoPlayer();
+
+ // Test the method
+ NativeAudioTrackData nativeData = videoPlayer.getAudioTracks();
+ List result = nativeData.getExoPlayerTracks();
+
+ // Verify results
+ assertNotNull(result);
+ assertEquals(2, result.size());
+
+ // Verify track indices are correct
+ ExoPlayerAudioTrackData track1 = result.get(0);
+ ExoPlayerAudioTrackData track2 = result.get(1);
+ assertEquals(0L, track1.getGroupIndex());
+ assertEquals(0L, track1.getTrackIndex());
+ assertEquals(0L, track2.getGroupIndex());
+ assertEquals(1L, track2.getTrackIndex());
+ // Tracks have same group but different track indices
+ assertEquals(track1.getGroupIndex(), track2.getGroupIndex());
+ assertNotEquals(track1.getTrackIndex(), track2.getTrackIndex());
+
+ videoPlayer.dispose();
+ }
+
+ @Test
+ public void testSelectAudioTrack_validIndices() {
+ DefaultTrackSelector mockTrackSelector = mock(DefaultTrackSelector.class);
+ DefaultTrackSelector.Parameters mockParameters = mock(DefaultTrackSelector.Parameters.class);
+ DefaultTrackSelector.Parameters.Builder mockBuilder =
+ mock(DefaultTrackSelector.Parameters.Builder.class);
+
+ Tracks mockTracks = mock(Tracks.class);
+ Tracks.Group mockAudioGroup = mock(Tracks.Group.class);
+
+ Format audioFormat =
+ new Format.Builder().setId("audio_track_1").setLabel("English").setLanguage("en").build();
+
+ // Create a real TrackGroup with the format
+ TrackGroup trackGroup = new TrackGroup(audioFormat);
+
+ // Mock audio group with 2 tracks
+ setGroupLength(mockAudioGroup, 2);
+ when(mockAudioGroup.getType()).thenReturn(C.TRACK_TYPE_AUDIO);
+ when(mockAudioGroup.getMediaTrackGroup()).thenReturn(trackGroup);
+
+ ImmutableList groups = ImmutableList.of(mockAudioGroup);
+ when(mockTracks.getGroups()).thenReturn(groups);
+
+ // Set up track selector BEFORE creating VideoPlayer
+ when(mockExoPlayer.getTrackSelector()).thenReturn(mockTrackSelector);
+ when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks);
+ when(mockTrackSelector.buildUponParameters()).thenReturn(mockBuilder);
+ when(mockBuilder.setOverrideForType(any(TrackSelectionOverride.class))).thenReturn(mockBuilder);
+ when(mockBuilder.build()).thenReturn(mockParameters);
+
+ VideoPlayer videoPlayer = createVideoPlayer();
+
+ // Test selecting a valid audio track
+ videoPlayer.selectAudioTrack(0, 0);
+
+ // Verify track selector was called
+ verify(mockTrackSelector).buildUponParameters();
+ verify(mockBuilder).setOverrideForType(any(TrackSelectionOverride.class));
+ verify(mockBuilder).build();
+ verify(mockTrackSelector).setParameters(mockParameters);
+
+ videoPlayer.dispose();
+ }
+
+ @Test
+ public void testSelectAudioTrack_nullTrackSelector() {
+ // Track selector is null by default in mock
+ VideoPlayer videoPlayer = createVideoPlayer();
+
+ assertThrows(IllegalStateException.class, () -> videoPlayer.selectAudioTrack(0, 0));
+
+ videoPlayer.dispose();
+ }
+
+ @Test
+ public void testSelectAudioTrack_invalidGroupIndex() {
+ DefaultTrackSelector mockTrackSelector = mock(DefaultTrackSelector.class);
+ Tracks mockTracks = mock(Tracks.class);
+ Tracks.Group mockAudioGroup = mock(Tracks.Group.class);
+
+ Format audioFormat =
+ new Format.Builder().setId("audio_track_1").setLabel("English").setLanguage("en").build();
+
+ ImmutableList groups = ImmutableList.of(mockAudioGroup);
+ when(mockTracks.getGroups()).thenReturn(groups);
+ when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks);
+ when(mockExoPlayer.getTrackSelector()).thenReturn(mockTrackSelector);
+
+ VideoPlayer videoPlayer = createVideoPlayer();
+
+ // Test with invalid group index (only 1 group exists at index 0)
+ assertThrows(IllegalArgumentException.class, () -> videoPlayer.selectAudioTrack(5, 0));
+
+ videoPlayer.dispose();
+ }
+
+ @Test
+ public void testSelectAudioTrack_invalidTrackIndex() {
+ DefaultTrackSelector mockTrackSelector = mock(DefaultTrackSelector.class);
+ Tracks mockTracks = mock(Tracks.class);
+ Tracks.Group mockAudioGroup = mock(Tracks.Group.class);
+
+ Format audioFormat =
+ new Format.Builder().setId("audio_track_1").setLabel("English").setLanguage("en").build();
+
+ // Mock audio group with only 1 track
+ setGroupLength(mockAudioGroup, 1);
+ when(mockAudioGroup.getType()).thenReturn(C.TRACK_TYPE_AUDIO);
+
+ ImmutableList groups = ImmutableList.of(mockAudioGroup);
+ when(mockTracks.getGroups()).thenReturn(groups);
+ when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks);
+ when(mockExoPlayer.getTrackSelector()).thenReturn(mockTrackSelector);
+
+ VideoPlayer videoPlayer = createVideoPlayer();
+
+ // Test with invalid track index (only 1 track exists at index 0)
+ assertThrows(IllegalArgumentException.class, () -> videoPlayer.selectAudioTrack(0, 5));
+
+ videoPlayer.dispose();
+ }
+
+ @Test
+ public void testSelectAudioTrack_nonAudioGroup() {
+ DefaultTrackSelector mockTrackSelector = mock(DefaultTrackSelector.class);
+ Tracks mockTracks = mock(Tracks.class);
+ Tracks.Group mockVideoGroup = mock(Tracks.Group.class);
+
+ // Mock video group (not audio)
+ setGroupLength(mockVideoGroup, 1);
+ when(mockVideoGroup.getType()).thenReturn(C.TRACK_TYPE_VIDEO);
+
+ ImmutableList groups = ImmutableList.of(mockVideoGroup);
+ when(mockTracks.getGroups()).thenReturn(groups);
+ when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks);
+ when(mockExoPlayer.getTrackSelector()).thenReturn(mockTrackSelector);
+
+ VideoPlayer videoPlayer = createVideoPlayer();
+
+ // Test selecting from a non-audio group
+ assertThrows(IllegalArgumentException.class, () -> videoPlayer.selectAudioTrack(0, 0));
+
+ videoPlayer.dispose();
+ }
+
+ @Test
+ public void testSelectAudioTrack_negativeIndices() {
+ DefaultTrackSelector mockTrackSelector = mock(DefaultTrackSelector.class);
+ Tracks mockTracks = mock(Tracks.class);
+ Tracks.Group mockAudioGroup = mock(Tracks.Group.class);
+
+ Format audioFormat =
+ new Format.Builder().setId("audio_track_1").setLabel("English").setLanguage("en").build();
+
+ ImmutableList groups = ImmutableList.of(mockAudioGroup);
+ when(mockTracks.getGroups()).thenReturn(groups);
+ when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks);
+ when(mockExoPlayer.getTrackSelector()).thenReturn(mockTrackSelector);
+
+ VideoPlayer videoPlayer = createVideoPlayer();
+
+ // Test with negative indices - should be caught by bounds checking
+ assertThrows(IllegalArgumentException.class, () -> videoPlayer.selectAudioTrack(-1, 0));
+
+ videoPlayer.dispose();
+ }
}
diff --git a/packages/video_player/video_player_android/example/pubspec.yaml b/packages/video_player/video_player_android/example/pubspec.yaml
index 4afc63d4990..07c5b497d5d 100644
--- a/packages/video_player/video_player_android/example/pubspec.yaml
+++ b/packages/video_player/video_player_android/example/pubspec.yaml
@@ -18,7 +18,7 @@ dependencies:
# The example app is bundled with the plugin so we use a path dependency on
# the parent directory to use the current plugin's version.
path: ../
- video_player_platform_interface: ^6.3.0
+ video_player_platform_interface: ^6.6.0
dev_dependencies:
espresso: ^0.4.0
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 78db9273e5d..84249bd41af 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
@@ -225,6 +225,47 @@ class AndroidVideoPlayer extends VideoPlayerPlatform {
return _api.setMixWithOthers(mixWithOthers);
}
+ @override
+ Future> getAudioTracks(int playerId) async {
+ final NativeAudioTrackData nativeData = await _playerWith(
+ id: playerId,
+ ).getAudioTracks();
+ final tracks = [];
+
+ // Convert ExoPlayer tracks to VideoAudioTrack
+ if (nativeData.exoPlayerTracks != null) {
+ for (final ExoPlayerAudioTrackData track in nativeData.exoPlayerTracks!) {
+ // Construct a string ID from groupIndex and trackIndex for compatibility
+ final trackId = '${track.groupIndex}_${track.trackIndex}';
+ tracks.add(
+ VideoAudioTrack(
+ id: trackId,
+ label: track.label,
+ language: track.language,
+ isSelected: track.isSelected,
+ bitrate: track.bitrate,
+ sampleRate: track.sampleRate,
+ channelCount: track.channelCount,
+ codec: track.codec,
+ ),
+ );
+ }
+ }
+
+ return tracks;
+ }
+
+ @override
+ Future selectAudioTrack(int playerId, String trackId) {
+ return _playerWith(id: playerId).selectAudioTrack(trackId);
+ }
+
+ @override
+ bool isAudioTrackSupportAvailable() {
+ // Android with ExoPlayer supports audio track selection
+ return true;
+ }
+
_PlayerInstance _playerWith({required int id}) {
final _PlayerInstance? player = _players[id];
return player ?? (throw StateError('No active player with ID $id.'));
@@ -272,6 +313,7 @@ class _PlayerInstance {
Timer? _bufferPollingTimer;
int _lastBufferPosition = -1;
bool _isBuffering = false;
+ Completer? _audioTrackSelectionCompleter;
final VideoPlayerViewState viewState;
@@ -307,6 +349,41 @@ class _PlayerInstance {
return _eventStreamController.stream;
}
+ Future getAudioTracks() {
+ return _api.getAudioTracks();
+ }
+
+ Future selectAudioTrack(String trackId) async {
+ // Parse the trackId to get groupIndex and trackIndex
+ final List parts = trackId.split('_');
+ if (parts.length != 2) {
+ throw ArgumentError(
+ 'Invalid trackId format: "$trackId". Expected format: "groupIndex_trackIndex"',
+ );
+ }
+
+ final int groupIndex = int.parse(parts[0]);
+ final int trackIndex = int.parse(parts[1]);
+
+ // Create a completer to wait for the track selection to complete
+ _audioTrackSelectionCompleter = Completer();
+
+ try {
+ await _api.selectAudioTrack(groupIndex, trackIndex);
+
+ // Wait for the onTracksChanged event from ExoPlayer with a timeout
+ await _audioTrackSelectionCompleter!.future.timeout(
+ const Duration(seconds: 5),
+ onTimeout: () {
+ // If we timeout, just continue - the track may still have been selected
+ // This is a fallback in case the event doesn't arrive for some reason
+ },
+ );
+ } finally {
+ _audioTrackSelectionCompleter = null;
+ }
+ }
+
Future dispose() async {
_isDisposed = true;
_bufferPollingTimer?.cancel();
@@ -403,6 +480,13 @@ class _PlayerInstance {
if (event.state != PlatformPlaybackState.buffering) {
_setBuffering(false);
}
+ case AudioTrackChangedEvent _:
+ // Complete the audio track selection completer if it exists
+ // This signals that the track selection has completed
+ if (_audioTrackSelectionCompleter != null &&
+ !_audioTrackSelectionCompleter!.isCompleted) {
+ _audioTrackSelectionCompleter!.complete();
+ }
}
}
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 3339059181f..f7a50731277 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
@@ -178,6 +178,46 @@ class IsPlayingStateEvent extends PlatformVideoEvent {
int get hashCode => Object.hashAll(_toList());
}
+/// Sent when audio tracks change.
+///
+/// This includes when the selected audio track changes after calling selectAudioTrack.
+/// Corresponds to ExoPlayer's onTracksChanged.
+class AudioTrackChangedEvent extends PlatformVideoEvent {
+ AudioTrackChangedEvent({this.selectedTrackId});
+
+ /// The ID of the newly selected audio track, if any.
+ String? selectedTrackId;
+
+ List