From b544b4531dbd7b952e2b56bf131e557ce06d6619 Mon Sep 17 00:00:00 2001 From: nateshmbhat Date: Fri, 7 Nov 2025 21:57:40 +0530 Subject: [PATCH 1/7] feat(video_player_android): implement audio track selection API - Added getAudioTracks() method to retrieve available audio tracks with metadata (bitrate, sample rate, channel count, codec) - Added selectAudioTrack() method to switch between audio tracks using ExoPlayer's track selector - Implemented onTracksChanged listener to notify when audio track selection changes --- .../video_player_android/CHANGELOG.md | 4 + .../videoplayer/ExoPlayerEventListener.java | 33 ++ .../plugins/videoplayer/VideoPlayer.java | 123 ++++++ .../videoplayer/VideoPlayerCallbacks.java | 2 + .../VideoPlayerEventCallbacks.java | 5 + .../platformview/PlatformViewVideoPlayer.java | 6 + .../texture/TextureVideoPlayer.java | 6 + .../flutter/plugins/videoplayer/Messages.kt | 326 ++++++++++++++- .../plugins/videoplayer/AudioTracksTest.java | 370 +++++++++++++++++ .../video_player_android/example/pubspec.yaml | 2 +- .../lib/src/android_video_player.dart | 84 ++++ .../lib/src/messages.g.dart | 375 +++++++++++++++++- .../pigeons/messages.dart | 81 ++++ .../video_player_android/pubspec.yaml | 2 +- 14 files changed, 1406 insertions(+), 13 deletions(-) create mode 100644 packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/AudioTracksTest.java diff --git a/packages/video_player/video_player_android/CHANGELOG.md b/packages/video_player/video_player_android/CHANGELOG.md index a6decfda03b..570df98784b 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.17 * Moves video event processing logic to Dart, and fixes an issue where buffer 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 d297dad31cc..ca6d185a989 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 @@ -7,14 +7,23 @@ import static androidx.media3.common.Player.REPEAT_MODE_ALL; import static androidx.media3.common.Player.REPEAT_MODE_OFF; +import android.util.Log; import androidx.annotation.NonNull; 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 +35,7 @@ public abstract class VideoPlayer implements VideoPlayerInstanceApi { @Nullable protected final SurfaceProducer surfaceProducer; @Nullable private DisposeHandler disposeHandler; @NonNull protected ExoPlayer exoPlayer; + @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,7 @@ public interface DisposeHandler { void onDispose(); } + @UnstableApi public VideoPlayer( @NonNull VideoPlayerCallbacks events, @NonNull MediaItem mediaItem, @@ -52,6 +63,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)); @@ -122,6 +139,112 @@ public ExoPlayer getExoPlayer() { return exoPlayer; } + @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); + } + + @UnstableApi + @Override + public void selectAudioTrack(long groupIndex, long trackIndex) { + if (trackSelector == null) { + Log.w("VideoPlayer", "Cannot select audio track: track selector is null"); + return; + } + + try { + + // Get current tracks + Tracks tracks = exoPlayer.getCurrentTracks(); + + if (groupIndex >= tracks.getGroups().size()) { + Log.w( + "VideoPlayer", + "Cannot select audio track: groupIndex " + + groupIndex + + " is out of bounds (available groups: " + + tracks.getGroups().size() + + ")"); + return; + } + + Tracks.Group group = tracks.getGroups().get((int) groupIndex); + + // Verify it's an audio track and the track index is valid + if (group.getType() != C.TRACK_TYPE_AUDIO || (int) trackIndex >= group.length) { + if (group.getType() != C.TRACK_TYPE_AUDIO) { + Log.w( + "VideoPlayer", + "Cannot select audio track: group at index " + + groupIndex + + " is not an audio track (type: " + + group.getType() + + ")"); + } else { + Log.w( + "VideoPlayer", + "Cannot select audio track: trackIndex " + + trackIndex + + " is out of bounds (available tracks in group: " + + group.length + + ")"); + } + return; + } + + // 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()); + + } catch (ArrayIndexOutOfBoundsException e) { + Log.w( + "VideoPlayer", + "Cannot select audio track: invalid indices (groupIndex: " + + groupIndex + + ", trackIndex: " + + trackIndex + + "). " + + e.getMessage()); + } + } + 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/platformview/PlatformViewVideoPlayer.java b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/platformview/PlatformViewVideoPlayer.java index 34b7533bd38..355e82d6fb0 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,7 @@ * displaying the video in the app. */ public class PlatformViewVideoPlayer extends VideoPlayer { + @UnstableApi @VisibleForTesting public PlatformViewVideoPlayer( @NonNull VideoPlayerCallbacks events, @@ -40,6 +42,7 @@ public PlatformViewVideoPlayer( * @param options options for playback. * @return a video player instance. */ + @UnstableApi @NonNull public static PlatformViewVideoPlayer create( @NonNull Context context, @@ -51,8 +54,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..4f0999248f2 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,7 @@ public final class TextureVideoPlayer extends VideoPlayer implements SurfaceProd * @param options options for playback. * @return a video player instance. */ + @UnstableApi @NonNull public static TextureVideoPlayer create( @NonNull Context context, @@ -52,13 +54,17 @@ 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(); }); } + @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 800026ab4d8..75bb515d245 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/AudioTracksTest.java b/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/AudioTracksTest.java new file mode 100644 index 00000000000..0152c39fc67 --- /dev/null +++ b/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/AudioTracksTest.java @@ -0,0 +1,370 @@ +// Copyright 2013 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +package io.flutter.plugins.videoplayer; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.*; + +import androidx.media3.common.C; +import androidx.media3.common.Format; +import androidx.media3.common.MediaItem; +import androidx.media3.common.Tracks; +import androidx.media3.exoplayer.ExoPlayer; +import com.google.common.collect.ImmutableList; +import io.flutter.view.TextureRegistry; +import java.lang.reflect.Field; +import java.util.List; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.robolectric.RobolectricTestRunner; + +@RunWith(RobolectricTestRunner.class) +public class AudioTracksTest { + + @Mock private ExoPlayer mockExoPlayer; + @Mock private VideoPlayerCallbacks mockVideoPlayerCallbacks; + @Mock private TextureRegistry.SurfaceProducer mockSurfaceProducer; + @Mock private MediaItem mockMediaItem; + @Mock private VideoPlayerOptions mockVideoPlayerOptions; + @Mock private Tracks mockTracks; + @Mock private Tracks.Group mockAudioGroup1; + @Mock private Tracks.Group mockAudioGroup2; + @Mock private Tracks.Group mockVideoGroup; + + private VideoPlayer videoPlayer; + + @Before + public void setUp() { + MockitoAnnotations.openMocks(this); + + // Create a concrete VideoPlayer implementation for testing + videoPlayer = + new VideoPlayer( + mockVideoPlayerCallbacks, + mockMediaItem, + mockVideoPlayerOptions, + mockSurfaceProducer, + () -> mockExoPlayer) { + @Override + protected ExoPlayerEventListener createExoPlayerEventListener( + ExoPlayer exoPlayer, TextureRegistry.SurfaceProducer surfaceProducer) { + return mock(ExoPlayerEventListener.class); + } + }; + } + + // 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) { + // If reflection fails, we'll handle it in the test + throw new RuntimeException("Failed to set length field", e); + } + } + + @Test + public void testGetAudioTracks_withMultipleAudioTracks() { + // 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); + + // 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()); + } + + @Test + public void testGetAudioTracks_withNoAudioTracks() { + // 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); + + // Test the method + NativeAudioTrackData nativeData = videoPlayer.getAudioTracks(); + List result = nativeData.getExoPlayerTracks(); + + // Verify results + assertNotNull(result); + assertEquals(0, result.size()); + } + + @Test + public void testGetAudioTracks_withNullValues() { + // 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); + + // 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()); + } + + @Test + public void testGetAudioTracks_withMultipleTracksInSameGroup() { + // 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); + + // 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()); + } + + @Test + public void testGetAudioTracks_withDifferentCodecs() { + // Test various codec formats + Format aacFormat = new Format.Builder().setCodecs("mp4a.40.2").setLabel("AAC Track").build(); + + Format ac3Format = new Format.Builder().setCodecs("ac-3").setLabel("AC3 Track").build(); + + Format eac3Format = new Format.Builder().setCodecs("ec-3").setLabel("EAC3 Track").build(); + + // Mock audio group with different codecs + setGroupLength(mockAudioGroup1, 3); + when(mockAudioGroup1.getType()).thenReturn(C.TRACK_TYPE_AUDIO); + when(mockAudioGroup1.getTrackFormat(0)).thenReturn(aacFormat); + when(mockAudioGroup1.getTrackFormat(1)).thenReturn(ac3Format); + when(mockAudioGroup1.getTrackFormat(2)).thenReturn(eac3Format); + when(mockAudioGroup1.isTrackSelected(anyInt())).thenReturn(false); + + ImmutableList groups = ImmutableList.of(mockAudioGroup1); + when(mockTracks.getGroups()).thenReturn(groups); + when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks); + + // Test the method + NativeAudioTrackData nativeData = videoPlayer.getAudioTracks(); + List result = nativeData.getExoPlayerTracks(); + + // Verify results + assertNotNull(result); + assertEquals(3, result.size()); + + assertEquals("mp4a.40.2", result.get(0).getCodec()); + assertEquals("ac-3", result.get(1).getCodec()); + assertEquals("ec-3", result.get(2).getCodec()); + } + + @Test + public void testGetAudioTracks_withHighBitrateValues() { + // Test with high bitrate values + Format highBitrateFormat = + new Format.Builder() + .setId("high_bitrate_track") + .setLabel("High Quality") + .setAverageBitrate(1536000) // 1.5 Mbps + .setSampleRate(96000) // 96 kHz + .setChannelCount(8) // 7.1 surround + .build(); + + // Mock audio group with high bitrate format + setGroupLength(mockAudioGroup1, 1); + when(mockAudioGroup1.getType()).thenReturn(C.TRACK_TYPE_AUDIO); + when(mockAudioGroup1.getTrackFormat(0)).thenReturn(highBitrateFormat); + when(mockAudioGroup1.isTrackSelected(0)).thenReturn(true); + + ImmutableList groups = ImmutableList.of(mockAudioGroup1); + when(mockTracks.getGroups()).thenReturn(groups); + when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks); + + // 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(Long.valueOf(1536000), track.getBitrate()); + assertEquals(Long.valueOf(96000), track.getSampleRate()); + assertEquals(Long.valueOf(8), track.getChannelCount()); + } + + @Test + public void testGetAudioTracks_performanceWithManyTracks() { + // Test performance with many audio tracks + int numGroups = 50; + List groups = new java.util.ArrayList<>(); + + for (int i = 0; i < numGroups; i++) { + Format format = + new Format.Builder().setId("track_" + i).setLabel("Track " + i).setLanguage("en").build(); + + Tracks.Group mockGroup = mock(Tracks.Group.class); + setGroupLength(mockGroup, 1); + when(mockGroup.getType()).thenReturn(C.TRACK_TYPE_AUDIO); + when(mockGroup.getTrackFormat(0)).thenReturn(format); + when(mockGroup.isTrackSelected(0)).thenReturn(i == 0); // Only first track selected + groups.add(mockGroup); + } + + when(mockTracks.getGroups()).thenReturn(ImmutableList.copyOf(groups)); + when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks); + + // Measure performance + long startTime = System.currentTimeMillis(); + NativeAudioTrackData nativeData = videoPlayer.getAudioTracks(); + List result = nativeData.getExoPlayerTracks(); + long endTime = System.currentTimeMillis(); + + // Verify results + assertNotNull(result); + assertEquals(numGroups, result.size()); + + // Should complete within reasonable time (1 second for 50 tracks) + assertTrue( + "getAudioTracks took too long: " + (endTime - startTime) + "ms", + (endTime - startTime) < 1000); + } +} 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 f65b83b8a84..fbcb38fdc31 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 List 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 String 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 5674729aeb1..75bce2b2cb1 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 _toList() { + return [selectedTrackId]; + } + + Object encode() { + return _toList(); + } + + static AudioTrackChangedEvent decode(Object result) { + result as List; + return AudioTrackChangedEvent(selectedTrackId: result[0] as String?); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! AudioTrackChangedEvent || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()); +} + /// Information passed to the platform view creation. class PlatformVideoViewCreationParams { PlatformVideoViewCreationParams({required this.playerId}); @@ -307,6 +347,247 @@ class TexturePlayerIds { int get hashCode => Object.hashAll(_toList()); } +class PlaybackState { + PlaybackState({required this.playPosition, required this.bufferPosition}); + + /// The current playback position, in milliseconds. + int playPosition; + + /// The current buffer position, in milliseconds. + int bufferPosition; + + List _toList() { + return [playPosition, bufferPosition]; + } + + Object encode() { + return _toList(); + } + + static PlaybackState decode(Object result) { + result as List; + return PlaybackState( + playPosition: result[0]! as int, + bufferPosition: result[1]! as int, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! PlaybackState || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()); +} + +/// Represents an audio track in a video. +class AudioTrackMessage { + AudioTrackMessage({ + required this.id, + required this.label, + required this.language, + required this.isSelected, + this.bitrate, + this.sampleRate, + this.channelCount, + this.codec, + }); + + String id; + + String label; + + String language; + + bool isSelected; + + int? bitrate; + + int? sampleRate; + + int? channelCount; + + String? codec; + + List _toList() { + return [ + id, + label, + language, + isSelected, + bitrate, + sampleRate, + channelCount, + codec, + ]; + } + + Object encode() { + return _toList(); + } + + static AudioTrackMessage decode(Object result) { + result as List; + return AudioTrackMessage( + id: result[0]! as String, + label: result[1]! as String, + language: result[2]! as String, + isSelected: result[3]! as bool, + bitrate: result[4] as int?, + sampleRate: result[5] as int?, + channelCount: result[6] as int?, + codec: result[7] as String?, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! AudioTrackMessage || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()); +} + +/// Raw audio track data from ExoPlayer Format objects. +class ExoPlayerAudioTrackData { + ExoPlayerAudioTrackData({ + required this.groupIndex, + required this.trackIndex, + this.label, + this.language, + required this.isSelected, + this.bitrate, + this.sampleRate, + this.channelCount, + this.codec, + }); + + int groupIndex; + + int trackIndex; + + String? label; + + String? language; + + bool isSelected; + + int? bitrate; + + int? sampleRate; + + int? channelCount; + + String? codec; + + List _toList() { + return [ + groupIndex, + trackIndex, + label, + language, + isSelected, + bitrate, + sampleRate, + channelCount, + codec, + ]; + } + + Object encode() { + return _toList(); + } + + static ExoPlayerAudioTrackData decode(Object result) { + result as List; + return ExoPlayerAudioTrackData( + groupIndex: result[0]! as int, + trackIndex: result[1]! as int, + label: result[2] as String?, + language: result[3] as String?, + isSelected: result[4]! as bool, + bitrate: result[5] as int?, + sampleRate: result[6] as int?, + channelCount: result[7] as int?, + codec: result[8] as String?, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! ExoPlayerAudioTrackData || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()); +} + +/// Container for raw audio track data from Android ExoPlayer. +class NativeAudioTrackData { + NativeAudioTrackData({this.exoPlayerTracks}); + + /// ExoPlayer-based tracks + List? exoPlayerTracks; + + List _toList() { + return [exoPlayerTracks]; + } + + Object encode() { + return _toList(); + } + + static NativeAudioTrackData decode(Object result) { + result as List; + return NativeAudioTrackData( + exoPlayerTracks: (result[0] as List?) + ?.cast(), + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! NativeAudioTrackData || other.runtimeType != runtimeType) { + return false; + } + if (identical(this, other)) { + return true; + } + return _deepEquals(encode(), other.encode()); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + int get hashCode => Object.hashAll(_toList()); +} + class _PigeonCodec extends StandardMessageCodec { const _PigeonCodec(); @override @@ -329,15 +610,30 @@ class _PigeonCodec extends StandardMessageCodec { } else if (value is IsPlayingStateEvent) { buffer.putUint8(133); writeValue(buffer, value.encode()); - } else if (value is PlatformVideoViewCreationParams) { + } else if (value is AudioTrackChangedEvent) { buffer.putUint8(134); writeValue(buffer, value.encode()); - } else if (value is CreationOptions) { + } else if (value is PlatformVideoViewCreationParams) { buffer.putUint8(135); writeValue(buffer, value.encode()); - } else if (value is TexturePlayerIds) { + } else if (value is CreationOptions) { buffer.putUint8(136); writeValue(buffer, value.encode()); + } else if (value is TexturePlayerIds) { + buffer.putUint8(137); + writeValue(buffer, value.encode()); + } else if (value is PlaybackState) { + buffer.putUint8(138); + writeValue(buffer, value.encode()); + } else if (value is AudioTrackMessage) { + buffer.putUint8(139); + writeValue(buffer, value.encode()); + } else if (value is ExoPlayerAudioTrackData) { + buffer.putUint8(140); + writeValue(buffer, value.encode()); + } else if (value is NativeAudioTrackData) { + buffer.putUint8(141); + writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); } @@ -359,11 +655,21 @@ class _PigeonCodec extends StandardMessageCodec { case 133: return IsPlayingStateEvent.decode(readValue(buffer)!); case 134: - return PlatformVideoViewCreationParams.decode(readValue(buffer)!); + return AudioTrackChangedEvent.decode(readValue(buffer)!); case 135: - return CreationOptions.decode(readValue(buffer)!); + return PlatformVideoViewCreationParams.decode(readValue(buffer)!); case 136: + return CreationOptions.decode(readValue(buffer)!); + case 137: return TexturePlayerIds.decode(readValue(buffer)!); + case 138: + return PlaybackState.decode(readValue(buffer)!); + case 139: + return AudioTrackMessage.decode(readValue(buffer)!); + case 140: + return ExoPlayerAudioTrackData.decode(readValue(buffer)!); + case 141: + return NativeAudioTrackData.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); } @@ -809,6 +1115,65 @@ class VideoPlayerInstanceApi { return (pigeonVar_replyList[0] as int?)!; } } + + /// Gets the available audio tracks for the video. + Future getAudioTracks() async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.video_player_android.VideoPlayerInstanceApi.getAudioTracks$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as NativeAudioTrackData?)!; + } + } + + /// Selects which audio track is chosen for playback from its [groupIndex] and [trackIndex] + Future selectAudioTrack(int groupIndex, int trackIndex) async { + final String pigeonVar_channelName = + 'dev.flutter.pigeon.video_player_android.VideoPlayerInstanceApi.selectAudioTrack$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = + BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [groupIndex, trackIndex], + ); + final List? pigeonVar_replyList = + await pigeonVar_sendFuture as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else { + return; + } + } } Stream videoEvents({String instanceName = ''}) { diff --git a/packages/video_player/video_player_android/pigeons/messages.dart b/packages/video_player/video_player_android/pigeons/messages.dart index 6fee5973760..8666b074969 100644 --- a/packages/video_player/video_player_android/pigeons/messages.dart +++ b/packages/video_player/video_player_android/pigeons/messages.dart @@ -51,6 +51,15 @@ class IsPlayingStateEvent extends PlatformVideoEvent { late final bool isPlaying; } +/// 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 { + /// The ID of the newly selected audio track, if any. + late final String? selectedTrackId; +} + /// Information passed to the platform view creation. class PlatformVideoViewCreationParams { const PlatformVideoViewCreationParams({required this.playerId}); @@ -73,6 +82,72 @@ class TexturePlayerIds { final int textureId; } +class PlaybackState { + PlaybackState({required this.playPosition, required this.bufferPosition}); + + /// The current playback position, in milliseconds. + final int playPosition; + + /// The current buffer position, in milliseconds. + final int bufferPosition; +} + +/// Represents an audio track in a video. +class AudioTrackMessage { + AudioTrackMessage({ + required this.id, + required this.label, + required this.language, + required this.isSelected, + this.bitrate, + this.sampleRate, + this.channelCount, + this.codec, + }); + + String id; + String label; + String language; + bool isSelected; + int? bitrate; + int? sampleRate; + int? channelCount; + String? codec; +} + +/// Raw audio track data from ExoPlayer Format objects. +class ExoPlayerAudioTrackData { + ExoPlayerAudioTrackData({ + required this.groupIndex, + required this.trackIndex, + this.label, + this.language, + required this.isSelected, + this.bitrate, + this.sampleRate, + this.channelCount, + this.codec, + }); + + int groupIndex; + int trackIndex; + String? label; + String? language; + bool isSelected; + int? bitrate; + int? sampleRate; + int? channelCount; + String? codec; +} + +/// Container for raw audio track data from Android ExoPlayer. +class NativeAudioTrackData { + NativeAudioTrackData({this.exoPlayerTracks}); + + /// ExoPlayer-based tracks + List? exoPlayerTracks; +} + @HostApi() abstract class AndroidVideoPlayerApi { void initialize(); @@ -111,6 +186,12 @@ abstract class VideoPlayerInstanceApi { /// Returns the current buffer position, in milliseconds. int getBufferedPosition(); + + /// Gets the available audio tracks for the video. + NativeAudioTrackData getAudioTracks(); + + /// Selects which audio track is chosen for playback from its [groupIndex] and [trackIndex] + void selectAudioTrack(int groupIndex, int trackIndex); } @EventChannelApi() diff --git a/packages/video_player/video_player_android/pubspec.yaml b/packages/video_player/video_player_android/pubspec.yaml index 8c996569854..c3c7f4648be 100644 --- a/packages/video_player/video_player_android/pubspec.yaml +++ b/packages/video_player/video_player_android/pubspec.yaml @@ -20,7 +20,7 @@ flutter: dependencies: flutter: sdk: flutter - video_player_platform_interface: ^6.3.0 + video_player_platform_interface: ^6.6.0 dev_dependencies: build_runner: ^2.3.3 From a0a9a0d2c3fb86ff595d71c524e0773c0a23700b Mon Sep 17 00:00:00 2001 From: Natesh Bhat Date: Wed, 19 Nov 2025 20:10:32 +0530 Subject: [PATCH 2/7] Update packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayer.java Co-authored-by: Camille Simon <43054281+camsim99@users.noreply.github.com> --- .../java/io/flutter/plugins/videoplayer/VideoPlayer.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 ca6d185a989..b683dacc3b1 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 @@ -204,7 +204,6 @@ public void selectAudioTrack(long groupIndex, long trackIndex) { Tracks.Group group = tracks.getGroups().get((int) groupIndex); // Verify it's an audio track and the track index is valid - if (group.getType() != C.TRACK_TYPE_AUDIO || (int) trackIndex >= group.length) { if (group.getType() != C.TRACK_TYPE_AUDIO) { Log.w( "VideoPlayer", @@ -213,7 +212,9 @@ public void selectAudioTrack(long groupIndex, long trackIndex) { + " is not an audio track (type: " + group.getType() + ")"); - } else { + return; + } + if ((int) trackIndex >= group.length) { Log.w( "VideoPlayer", "Cannot select audio track: trackIndex " @@ -221,8 +222,7 @@ public void selectAudioTrack(long groupIndex, long trackIndex) { + " is out of bounds (available tracks in group: " + group.length + ")"); - } - return; + return; } // Get the track group and create a selection override From 061388d9b5dec9bff2b05706baa47177a47d6a7a Mon Sep 17 00:00:00 2001 From: nateshmbhat Date: Wed, 19 Nov 2025 20:48:28 +0530 Subject: [PATCH 3/7] refactor(video_player_android): improve audio track selection validation and consolidate tests - Replaced try-catch with explicit bounds checking for groupIndex and trackIndex - Added validation for negative indices - Removed redundant error handling logic - Consolidated audio track tests from separate AudioTracksTest.java into VideoPlayerTest.java - Improved code readability by removing nested conditional logic --- .../plugins/videoplayer/VideoPlayer.java | 89 ++-- .../plugins/videoplayer/AudioTracksTest.java | 370 ---------------- .../plugins/videoplayer/VideoPlayerTest.java | 405 ++++++++++++++++++ 3 files changed, 444 insertions(+), 420 deletions(-) delete mode 100644 packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/AudioTracksTest.java 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 ca6d185a989..5f0fd693adc 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 @@ -185,64 +185,53 @@ public void selectAudioTrack(long groupIndex, long trackIndex) { return; } - try { - - // Get current tracks - Tracks tracks = exoPlayer.getCurrentTracks(); - - if (groupIndex >= tracks.getGroups().size()) { - Log.w( - "VideoPlayer", - "Cannot select audio track: groupIndex " - + groupIndex - + " is out of bounds (available groups: " - + tracks.getGroups().size() - + ")"); - return; - } - - Tracks.Group group = tracks.getGroups().get((int) groupIndex); - - // Verify it's an audio track and the track index is valid - if (group.getType() != C.TRACK_TYPE_AUDIO || (int) trackIndex >= group.length) { - if (group.getType() != C.TRACK_TYPE_AUDIO) { - Log.w( - "VideoPlayer", - "Cannot select audio track: group at index " - + groupIndex - + " is not an audio track (type: " - + group.getType() - + ")"); - } else { - Log.w( - "VideoPlayer", - "Cannot select audio track: trackIndex " - + trackIndex - + " is out of bounds (available tracks in group: " - + group.length - + ")"); - } - return; - } + // Get current tracks + Tracks tracks = exoPlayer.getCurrentTracks(); - // Get the track group and create a selection override - TrackGroup trackGroup = group.getMediaTrackGroup(); - TrackSelectionOverride override = new TrackSelectionOverride(trackGroup, (int) trackIndex); + if (groupIndex < 0 || groupIndex >= tracks.getGroups().size()) { + Log.w( + "VideoPlayer", + "Cannot select audio track: groupIndex " + + groupIndex + + " is out of bounds (available groups: " + + tracks.getGroups().size() + + ")"); + return; + } - // Apply the track selection override - trackSelector.setParameters( - trackSelector.buildUponParameters().setOverrideForType(override).build()); + Tracks.Group group = tracks.getGroups().get((int) groupIndex); - } catch (ArrayIndexOutOfBoundsException e) { + // Verify it's an audio track + if (group.getType() != C.TRACK_TYPE_AUDIO) { Log.w( "VideoPlayer", - "Cannot select audio track: invalid indices (groupIndex: " + "Cannot select audio track: group at index " + groupIndex - + ", trackIndex: " + + " is not an audio track (type: " + + group.getType() + + ")"); + return; + } + + // Verify the track index is valid + if (trackIndex < 0 || (int) trackIndex >= group.length) { + Log.w( + "VideoPlayer", + "Cannot select audio track: trackIndex " + trackIndex - + "). " - + e.getMessage()); + + " is out of bounds (available tracks in group: " + + group.length + + ")"); + return; } + + // 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() { diff --git a/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/AudioTracksTest.java b/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/AudioTracksTest.java deleted file mode 100644 index 0152c39fc67..00000000000 --- a/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/AudioTracksTest.java +++ /dev/null @@ -1,370 +0,0 @@ -// Copyright 2013 The Flutter Authors -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -package io.flutter.plugins.videoplayer; - -import static org.junit.Assert.*; -import static org.mockito.Mockito.*; - -import androidx.media3.common.C; -import androidx.media3.common.Format; -import androidx.media3.common.MediaItem; -import androidx.media3.common.Tracks; -import androidx.media3.exoplayer.ExoPlayer; -import com.google.common.collect.ImmutableList; -import io.flutter.view.TextureRegistry; -import java.lang.reflect.Field; -import java.util.List; -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; -import org.robolectric.RobolectricTestRunner; - -@RunWith(RobolectricTestRunner.class) -public class AudioTracksTest { - - @Mock private ExoPlayer mockExoPlayer; - @Mock private VideoPlayerCallbacks mockVideoPlayerCallbacks; - @Mock private TextureRegistry.SurfaceProducer mockSurfaceProducer; - @Mock private MediaItem mockMediaItem; - @Mock private VideoPlayerOptions mockVideoPlayerOptions; - @Mock private Tracks mockTracks; - @Mock private Tracks.Group mockAudioGroup1; - @Mock private Tracks.Group mockAudioGroup2; - @Mock private Tracks.Group mockVideoGroup; - - private VideoPlayer videoPlayer; - - @Before - public void setUp() { - MockitoAnnotations.openMocks(this); - - // Create a concrete VideoPlayer implementation for testing - videoPlayer = - new VideoPlayer( - mockVideoPlayerCallbacks, - mockMediaItem, - mockVideoPlayerOptions, - mockSurfaceProducer, - () -> mockExoPlayer) { - @Override - protected ExoPlayerEventListener createExoPlayerEventListener( - ExoPlayer exoPlayer, TextureRegistry.SurfaceProducer surfaceProducer) { - return mock(ExoPlayerEventListener.class); - } - }; - } - - // 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) { - // If reflection fails, we'll handle it in the test - throw new RuntimeException("Failed to set length field", e); - } - } - - @Test - public void testGetAudioTracks_withMultipleAudioTracks() { - // 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); - - // 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()); - } - - @Test - public void testGetAudioTracks_withNoAudioTracks() { - // 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); - - // Test the method - NativeAudioTrackData nativeData = videoPlayer.getAudioTracks(); - List result = nativeData.getExoPlayerTracks(); - - // Verify results - assertNotNull(result); - assertEquals(0, result.size()); - } - - @Test - public void testGetAudioTracks_withNullValues() { - // 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); - - // 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()); - } - - @Test - public void testGetAudioTracks_withMultipleTracksInSameGroup() { - // 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); - - // 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()); - } - - @Test - public void testGetAudioTracks_withDifferentCodecs() { - // Test various codec formats - Format aacFormat = new Format.Builder().setCodecs("mp4a.40.2").setLabel("AAC Track").build(); - - Format ac3Format = new Format.Builder().setCodecs("ac-3").setLabel("AC3 Track").build(); - - Format eac3Format = new Format.Builder().setCodecs("ec-3").setLabel("EAC3 Track").build(); - - // Mock audio group with different codecs - setGroupLength(mockAudioGroup1, 3); - when(mockAudioGroup1.getType()).thenReturn(C.TRACK_TYPE_AUDIO); - when(mockAudioGroup1.getTrackFormat(0)).thenReturn(aacFormat); - when(mockAudioGroup1.getTrackFormat(1)).thenReturn(ac3Format); - when(mockAudioGroup1.getTrackFormat(2)).thenReturn(eac3Format); - when(mockAudioGroup1.isTrackSelected(anyInt())).thenReturn(false); - - ImmutableList groups = ImmutableList.of(mockAudioGroup1); - when(mockTracks.getGroups()).thenReturn(groups); - when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks); - - // Test the method - NativeAudioTrackData nativeData = videoPlayer.getAudioTracks(); - List result = nativeData.getExoPlayerTracks(); - - // Verify results - assertNotNull(result); - assertEquals(3, result.size()); - - assertEquals("mp4a.40.2", result.get(0).getCodec()); - assertEquals("ac-3", result.get(1).getCodec()); - assertEquals("ec-3", result.get(2).getCodec()); - } - - @Test - public void testGetAudioTracks_withHighBitrateValues() { - // Test with high bitrate values - Format highBitrateFormat = - new Format.Builder() - .setId("high_bitrate_track") - .setLabel("High Quality") - .setAverageBitrate(1536000) // 1.5 Mbps - .setSampleRate(96000) // 96 kHz - .setChannelCount(8) // 7.1 surround - .build(); - - // Mock audio group with high bitrate format - setGroupLength(mockAudioGroup1, 1); - when(mockAudioGroup1.getType()).thenReturn(C.TRACK_TYPE_AUDIO); - when(mockAudioGroup1.getTrackFormat(0)).thenReturn(highBitrateFormat); - when(mockAudioGroup1.isTrackSelected(0)).thenReturn(true); - - ImmutableList groups = ImmutableList.of(mockAudioGroup1); - when(mockTracks.getGroups()).thenReturn(groups); - when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks); - - // 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(Long.valueOf(1536000), track.getBitrate()); - assertEquals(Long.valueOf(96000), track.getSampleRate()); - assertEquals(Long.valueOf(8), track.getChannelCount()); - } - - @Test - public void testGetAudioTracks_performanceWithManyTracks() { - // Test performance with many audio tracks - int numGroups = 50; - List groups = new java.util.ArrayList<>(); - - for (int i = 0; i < numGroups; i++) { - Format format = - new Format.Builder().setId("track_" + i).setLabel("Track " + i).setLanguage("en").build(); - - Tracks.Group mockGroup = mock(Tracks.Group.class); - setGroupLength(mockGroup, 1); - when(mockGroup.getType()).thenReturn(C.TRACK_TYPE_AUDIO); - when(mockGroup.getTrackFormat(0)).thenReturn(format); - when(mockGroup.isTrackSelected(0)).thenReturn(i == 0); // Only first track selected - groups.add(mockGroup); - } - - when(mockTracks.getGroups()).thenReturn(ImmutableList.copyOf(groups)); - when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks); - - // Measure performance - long startTime = System.currentTimeMillis(); - NativeAudioTrackData nativeData = videoPlayer.getAudioTracks(); - List result = nativeData.getExoPlayerTracks(); - long endTime = System.currentTimeMillis(); - - // Verify results - assertNotNull(result); - assertEquals(numGroups, result.size()); - - // Should complete within reasonable time (1 second for 50 tracks) - assertTrue( - "getAudioTracks took too long: " + (endTime - startTime) + "ms", - (endTime - startTime) < 1000); - } -} 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..ace4bb16fa1 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,11 @@ 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.assertTrue; import static org.mockito.Mockito.*; import androidx.annotation.NonNull; @@ -15,7 +20,14 @@ 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 java.lang.reflect.Field; +import java.util.List; import io.flutter.plugins.videoplayer.platformview.PlatformViewExoPlayerEventListener; import io.flutter.view.TextureRegistry.SurfaceProducer; import org.junit.Before; @@ -227,4 +239,397 @@ 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(); + + // Should not throw, just log warning + 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) + videoPlayer.selectAudioTrack(5, 0); + + // Verify track selector was NOT called + verify(mockTrackSelector, never()).setParameters(any(DefaultTrackSelector.Parameters.class)); + + 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) + videoPlayer.selectAudioTrack(0, 5); + + // Verify track selector was NOT called + verify(mockTrackSelector, never()).setParameters(any(DefaultTrackSelector.Parameters.class)); + + 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 + videoPlayer.selectAudioTrack(0, 0); + + // Verify track selector was NOT called + verify(mockTrackSelector, never()).setParameters(any(DefaultTrackSelector.Parameters.class)); + + 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 + videoPlayer.selectAudioTrack(-1, 0); + + // Verify track selector was NOT called + verify(mockTrackSelector, never()).setParameters(any(DefaultTrackSelector.Parameters.class)); + + videoPlayer.dispose(); + } } From 40b1b3fc8c3bf3fb3c46e1a815b4728980c801bf Mon Sep 17 00:00:00 2001 From: nateshmbhat Date: Wed, 19 Nov 2025 21:03:43 +0530 Subject: [PATCH 4/7] test(video_player_android): add audio track support availability test and fix formatting - Added test to verify isAudioTrackSupportAvailable returns true - Provided dummy values for NativeAudioTrackData and Future types in test setup - Fixed import ordering to follow convention (package imports before java imports) - Removed trailing whitespace in VideoPlayerTest.java - Added comment clarifying that comprehensive audio track tests are in Java layer --- .../plugins/videoplayer/VideoPlayerTest.java | 6 ++--- .../test/android_video_player_test.dart | 23 +++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) 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 ace4bb16fa1..292e45d2069 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 @@ -26,10 +26,10 @@ import androidx.media3.exoplayer.ExoPlayer; import androidx.media3.exoplayer.trackselection.DefaultTrackSelector; import com.google.common.collect.ImmutableList; -import java.lang.reflect.Field; -import java.util.List; 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; @@ -495,7 +495,7 @@ public void testSelectAudioTrack_validIndices() { 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); diff --git a/packages/video_player/video_player_android/test/android_video_player_test.dart b/packages/video_player/video_player_android/test/android_video_player_test.dart index 8ce82b0fdfb..1c437892e09 100644 --- a/packages/video_player/video_player_android/test/android_video_player_test.dart +++ b/packages/video_player/video_player_android/test/android_video_player_test.dart @@ -22,6 +22,17 @@ import 'android_video_player_test.mocks.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); + // Provide dummy values for audio track types + provideDummy( + NativeAudioTrackData(exoPlayerTracks: []), + ); + provideDummy>( + Future.value( + NativeAudioTrackData(exoPlayerTracks: []), + ), + ); + provideDummy>(Future.value()); + (AndroidVideoPlayer, MockAndroidVideoPlayerApi, MockVideoPlayerInstanceApi) setUpMockPlayer({required int playerId, int? textureId}) { final MockAndroidVideoPlayerApi pluginApi = MockAndroidVideoPlayerApi(); @@ -814,5 +825,17 @@ void main() { ); }); }); + + group('audio tracks', () { + // Note: Comprehensive audio track functionality tests are in the Java layer + // (VideoPlayerTest.java) where the actual implementation resides. + // These Dart tests verify the platform interface integration. + + test('isAudioTrackSupportAvailable returns true', () { + final (AndroidVideoPlayer player, _, _) = setUpMockPlayer(playerId: 1); + + expect(player.isAudioTrackSupportAvailable(), true); + }); + }); }); } From f0a3f3888be926a0a3ea223d25a1322202a499ac Mon Sep 17 00:00:00 2001 From: Natesh Bhat Date: Wed, 26 Nov 2025 20:52:59 +0530 Subject: [PATCH 5/7] Update packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayer.java Co-authored-by: Camille Simon <43054281+camsim99@users.noreply.github.com> --- .../main/java/io/flutter/plugins/videoplayer/VideoPlayer.java | 1 + 1 file changed, 1 insertion(+) 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 65a1643c415..f67d6ad257e 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 @@ -53,6 +53,7 @@ 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 From 25fa2069b71b82b2059fb0c2db6597bdae962241 Mon Sep 17 00:00:00 2001 From: nateshmbhat Date: Wed, 26 Nov 2025 21:26:03 +0530 Subject: [PATCH 6/7] test(video_player_android): change audio track validation from warnings to exceptions - Changed selectAudioTrack() to throw IllegalStateException when track selector is null - Changed selectAudioTrack() to throw IllegalArgumentException for invalid indices and non-audio tracks - Updated tests to expect exceptions instead of verifying no API calls - Added try-finally blocks in tests to ensure proper cleanup - Removed unused Log import - Added comprehensive Dart tests for getAudioTracks() and selectA --- .../plugins/videoplayer/VideoPlayer.java | 16 +- .../plugins/videoplayer/VideoPlayerTest.java | 72 +++++---- .../test/android_video_player_test.dart | 145 +++++++++++++++++- .../test/android_video_player_test.mocks.dart | 35 +++++ 4 files changed, 215 insertions(+), 53 deletions(-) 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 5f0fd693adc..0e74ebdf621 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 @@ -7,7 +7,6 @@ import static androidx.media3.common.Player.REPEAT_MODE_ALL; import static androidx.media3.common.Player.REPEAT_MODE_OFF; -import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.media3.common.AudioAttributes; @@ -181,48 +180,41 @@ public ExoPlayer getExoPlayer() { @Override public void selectAudioTrack(long groupIndex, long trackIndex) { if (trackSelector == null) { - Log.w("VideoPlayer", "Cannot select audio track: track selector is null"); - return; + 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()) { - Log.w( - "VideoPlayer", + throw new IllegalArgumentException( "Cannot select audio track: groupIndex " + groupIndex + " is out of bounds (available groups: " + tracks.getGroups().size() + ")"); - return; } Tracks.Group group = tracks.getGroups().get((int) groupIndex); // Verify it's an audio track if (group.getType() != C.TRACK_TYPE_AUDIO) { - Log.w( - "VideoPlayer", + throw new IllegalArgumentException( "Cannot select audio track: group at index " + groupIndex + " is not an audio track (type: " + group.getType() + ")"); - return; } // Verify the track index is valid if (trackIndex < 0 || (int) trackIndex >= group.length) { - Log.w( - "VideoPlayer", + throw new IllegalArgumentException( "Cannot select audio track: trackIndex " + trackIndex + " is out of bounds (available tracks in group: " + group.length + ")"); - return; } // Get the track group and create a selection override 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 292e45d2069..ffb87859034 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 @@ -517,18 +517,20 @@ public void testSelectAudioTrack_validIndices() { videoPlayer.dispose(); } - @Test + @Test(expected = IllegalStateException.class) public void testSelectAudioTrack_nullTrackSelector() { // Track selector is null by default in mock VideoPlayer videoPlayer = createVideoPlayer(); - // Should not throw, just log warning - videoPlayer.selectAudioTrack(0, 0); - - videoPlayer.dispose(); + try { + // Should throw IllegalStateException + videoPlayer.selectAudioTrack(0, 0); + } finally { + videoPlayer.dispose(); + } } - @Test + @Test(expected = IllegalArgumentException.class) public void testSelectAudioTrack_invalidGroupIndex() { DefaultTrackSelector mockTrackSelector = mock(DefaultTrackSelector.class); Tracks mockTracks = mock(Tracks.class); @@ -544,16 +546,15 @@ public void testSelectAudioTrack_invalidGroupIndex() { VideoPlayer videoPlayer = createVideoPlayer(); - // Test with invalid group index (only 1 group exists at index 0) - videoPlayer.selectAudioTrack(5, 0); - - // Verify track selector was NOT called - verify(mockTrackSelector, never()).setParameters(any(DefaultTrackSelector.Parameters.class)); - - videoPlayer.dispose(); + try { + // Test with invalid group index (only 1 group exists at index 0) + videoPlayer.selectAudioTrack(5, 0); + } finally { + videoPlayer.dispose(); + } } - @Test + @Test(expected = IllegalArgumentException.class) public void testSelectAudioTrack_invalidTrackIndex() { DefaultTrackSelector mockTrackSelector = mock(DefaultTrackSelector.class); Tracks mockTracks = mock(Tracks.class); @@ -573,16 +574,15 @@ public void testSelectAudioTrack_invalidTrackIndex() { VideoPlayer videoPlayer = createVideoPlayer(); - // Test with invalid track index (only 1 track exists at index 0) - videoPlayer.selectAudioTrack(0, 5); - - // Verify track selector was NOT called - verify(mockTrackSelector, never()).setParameters(any(DefaultTrackSelector.Parameters.class)); - - videoPlayer.dispose(); + try { + // Test with invalid track index (only 1 track exists at index 0) + videoPlayer.selectAudioTrack(0, 5); + } finally { + videoPlayer.dispose(); + } } - @Test + @Test(expected = IllegalArgumentException.class) public void testSelectAudioTrack_nonAudioGroup() { DefaultTrackSelector mockTrackSelector = mock(DefaultTrackSelector.class); Tracks mockTracks = mock(Tracks.class); @@ -599,16 +599,15 @@ public void testSelectAudioTrack_nonAudioGroup() { VideoPlayer videoPlayer = createVideoPlayer(); - // Test selecting from a non-audio group - videoPlayer.selectAudioTrack(0, 0); - - // Verify track selector was NOT called - verify(mockTrackSelector, never()).setParameters(any(DefaultTrackSelector.Parameters.class)); - - videoPlayer.dispose(); + try { + // Test selecting from a non-audio group + videoPlayer.selectAudioTrack(0, 0); + } finally { + videoPlayer.dispose(); + } } - @Test + @Test(expected = IllegalArgumentException.class) public void testSelectAudioTrack_negativeIndices() { DefaultTrackSelector mockTrackSelector = mock(DefaultTrackSelector.class); Tracks mockTracks = mock(Tracks.class); @@ -624,12 +623,11 @@ public void testSelectAudioTrack_negativeIndices() { VideoPlayer videoPlayer = createVideoPlayer(); - // Test with negative indices - should be caught by bounds checking - videoPlayer.selectAudioTrack(-1, 0); - - // Verify track selector was NOT called - verify(mockTrackSelector, never()).setParameters(any(DefaultTrackSelector.Parameters.class)); - - videoPlayer.dispose(); + try { + // Test with negative indices - should be caught by bounds checking + videoPlayer.selectAudioTrack(-1, 0); + } finally { + videoPlayer.dispose(); + } } } diff --git a/packages/video_player/video_player_android/test/android_video_player_test.dart b/packages/video_player/video_player_android/test/android_video_player_test.dart index 1c437892e09..8d1bd858922 100644 --- a/packages/video_player/video_player_android/test/android_video_player_test.dart +++ b/packages/video_player/video_player_android/test/android_video_player_test.dart @@ -827,15 +827,152 @@ void main() { }); group('audio tracks', () { - // Note: Comprehensive audio track functionality tests are in the Java layer - // (VideoPlayerTest.java) where the actual implementation resides. - // These Dart tests verify the platform interface integration. - test('isAudioTrackSupportAvailable returns true', () { final (AndroidVideoPlayer player, _, _) = setUpMockPlayer(playerId: 1); expect(player.isAudioTrackSupportAvailable(), true); }); + + test('getAudioTracks returns empty list when no tracks', () async { + final (AndroidVideoPlayer player, _, MockVideoPlayerInstanceApi api) = + setUpMockPlayer(playerId: 1); + when(api.getAudioTracks()).thenAnswer( + (_) async => NativeAudioTrackData( + exoPlayerTracks: [], + ), + ); + + final List tracks = await player.getAudioTracks(1); + + expect(tracks, isEmpty); + verify(api.getAudioTracks()); + }); + + test( + 'getAudioTracks converts native tracks to VideoAudioTrack', + () async { + final (AndroidVideoPlayer player, _, MockVideoPlayerInstanceApi api) = + setUpMockPlayer(playerId: 1); + when(api.getAudioTracks()).thenAnswer( + (_) async => NativeAudioTrackData( + exoPlayerTracks: [ + ExoPlayerAudioTrackData( + groupIndex: 0, + trackIndex: 1, + label: 'English', + language: 'en', + isSelected: true, + bitrate: 128000, + sampleRate: 44100, + channelCount: 2, + codec: 'mp4a.40.2', + ), + ExoPlayerAudioTrackData( + groupIndex: 0, + trackIndex: 2, + label: 'Spanish', + language: 'es', + isSelected: false, + bitrate: 128000, + sampleRate: 44100, + channelCount: 2, + codec: 'mp4a.40.2', + ), + ], + ), + ); + + final List tracks = await player.getAudioTracks(1); + + expect(tracks.length, 2); + + expect(tracks[0].id, '0_1'); + expect(tracks[0].label, 'English'); + expect(tracks[0].language, 'en'); + expect(tracks[0].isSelected, true); + expect(tracks[0].bitrate, 128000); + expect(tracks[0].sampleRate, 44100); + expect(tracks[0].channelCount, 2); + expect(tracks[0].codec, 'mp4a.40.2'); + + expect(tracks[1].id, '0_2'); + expect(tracks[1].label, 'Spanish'); + expect(tracks[1].language, 'es'); + expect(tracks[1].isSelected, false); + }, + ); + + test('getAudioTracks handles null exoPlayerTracks', () async { + final (AndroidVideoPlayer player, _, MockVideoPlayerInstanceApi api) = + setUpMockPlayer(playerId: 1); + when( + api.getAudioTracks(), + ).thenAnswer((_) async => NativeAudioTrackData(exoPlayerTracks: null)); + + final List tracks = await player.getAudioTracks(1); + + expect(tracks, isEmpty); + }); + + test('selectAudioTrack parses trackId and calls API', () async { + final ( + AndroidVideoPlayer player, + _, + MockVideoPlayerInstanceApi api, + StreamController streamController, + ) = setUpMockPlayerWithStream( + playerId: 1, + ); + when(api.selectAudioTrack(2, 3)).thenAnswer((_) async {}); + + // Start the selection and immediately send the track changed event + final Future selectionFuture = player.selectAudioTrack(1, '2_3'); + streamController.add(AudioTrackChangedEvent()); + await selectionFuture; + + verify(api.selectAudioTrack(2, 3)); + }); + + test('selectAudioTrack throws on invalid trackId format', () async { + final (AndroidVideoPlayer player, _, _) = setUpMockPlayer(playerId: 1); + + expect( + () => player.selectAudioTrack(1, 'invalid'), + throwsA(isA()), + ); + }); + + test('selectAudioTrack throws on trackId with too many parts', () async { + final (AndroidVideoPlayer player, _, _) = setUpMockPlayer(playerId: 1); + + expect( + () => player.selectAudioTrack(1, '1_2_3'), + throwsA(isA()), + ); + }); + + test('selectAudioTrack completes on AudioTrackChangedEvent', () async { + final ( + AndroidVideoPlayer player, + _, + MockVideoPlayerInstanceApi api, + StreamController streamController, + ) = setUpMockPlayerWithStream( + playerId: 1, + ); + when(api.selectAudioTrack(0, 1)).thenAnswer((_) async {}); + + // Start selection + final Future selectionFuture = player.selectAudioTrack(1, '0_1'); + + // Simulate the track changed event from ExoPlayer + streamController.add(AudioTrackChangedEvent()); + + // Should complete without timeout + await selectionFuture; + + verify(api.selectAudioTrack(0, 1)); + }); }); }); } diff --git a/packages/video_player/video_player_android/test/android_video_player_test.mocks.dart b/packages/video_player/video_player_android/test/android_video_player_test.mocks.dart index 2b75dc1e66e..212c9bde40c 100644 --- a/packages/video_player/video_player_android/test/android_video_player_test.mocks.dart +++ b/packages/video_player/video_player_android/test/android_video_player_test.mocks.dart @@ -29,6 +29,12 @@ class _FakeTexturePlayerIds_0 extends _i1.SmartFake : super(parent, parentInvocation); } +class _FakeNativeAudioTrackData_1 extends _i1.SmartFake + implements _i2.NativeAudioTrackData { + _FakeNativeAudioTrackData_1(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + /// A class which mocks [AndroidVideoPlayerApi]. /// /// See the documentation for Mockito's code generation for more information. @@ -217,4 +223,33 @@ class MockVideoPlayerInstanceApi extends _i1.Mock returnValueForMissingStub: _i4.Future.value(0), ) as _i4.Future); + + @override + _i4.Future<_i2.NativeAudioTrackData> getAudioTracks() => + (super.noSuchMethod( + Invocation.method(#getAudioTracks, []), + returnValue: _i4.Future<_i2.NativeAudioTrackData>.value( + _FakeNativeAudioTrackData_1( + this, + Invocation.method(#getAudioTracks, []), + ), + ), + returnValueForMissingStub: + _i4.Future<_i2.NativeAudioTrackData>.value( + _FakeNativeAudioTrackData_1( + this, + Invocation.method(#getAudioTracks, []), + ), + ), + ) + as _i4.Future<_i2.NativeAudioTrackData>); + + @override + _i4.Future selectAudioTrack(int? groupIndex, int? trackIndex) => + (super.noSuchMethod( + Invocation.method(#selectAudioTrack, [groupIndex, trackIndex]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); } From 15db47ef62a46800dffa4a1ab59df6e9a61a32ae Mon Sep 17 00:00:00 2001 From: nateshmbhat Date: Thu, 27 Nov 2025 13:54:54 +0530 Subject: [PATCH 7/7] style(video_player_android): apply dart format and omit obvious local types - Applied dart format to fix line length and formatting issues - Removed explicit type annotations for obvious local variable types per omit_obvious_local_variable_types lint rule - No functional changes, only code style improvements --- .../lib/src/android_video_player.dart | 32 +- .../test/android_video_player_test.dart | 319 +++++++----------- 2 files changed, 128 insertions(+), 223 deletions(-) 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 46db47f9248..3d613d4b5fe 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 @@ -17,9 +17,7 @@ VideoPlayerInstanceApi _productionApiProvider(int playerId) { } /// The non-test implementation of `_videoEventStreamProvider`. -Stream _productionVideoEventStreamProvider( - String streamIdentifier, -) { +Stream _productionVideoEventStreamProvider(String streamIdentifier) { return pigeon.videoEvents(instanceName: streamIdentifier); } @@ -29,8 +27,7 @@ class AndroidVideoPlayer extends VideoPlayerPlatform { /// Creates a new Android video player implementation instance. AndroidVideoPlayer({ @visibleForTesting AndroidVideoPlayerApi? pluginApi, - @visibleForTesting - VideoPlayerInstanceApi Function(int playerId)? playerApiProvider, + @visibleForTesting VideoPlayerInstanceApi Function(int playerId)? playerApiProvider, Stream Function(String streamIdentifier)? videoEventStreamProvider, }) : _api = pluginApi ?? AndroidVideoPlayerApi(), @@ -90,14 +87,9 @@ class AndroidVideoPlayer extends VideoPlayerPlatform { case DataSourceType.asset: final String? asset = dataSource.asset; if (asset == null) { - throw ArgumentError( - '"asset" must be non-null for an asset data source', - ); + throw ArgumentError('"asset" must be non-null for an asset data source'); } - final String key = await _api.getLookupKeyForAsset( - asset, - dataSource.package, - ); + final String key = await _api.getLookupKeyForAsset(asset, dataSource.package); uri = 'asset:///$key'; case DataSourceType.network: uri = dataSource.uri; @@ -213,9 +205,7 @@ class AndroidVideoPlayer extends VideoPlayerPlatform { final VideoPlayerViewState viewState = _playerWith(id: playerId).viewState; return switch (viewState) { - VideoPlayerTextureViewState(:final int textureId) => Texture( - textureId: textureId, - ), + VideoPlayerTextureViewState(:final int textureId) => Texture(textureId: textureId), VideoPlayerPlatformViewState() => PlatformViewPlayer(playerId: playerId), }; } @@ -230,13 +220,13 @@ class AndroidVideoPlayer extends VideoPlayerPlatform { final NativeAudioTrackData nativeData = await _playerWith( id: playerId, ).getAudioTracks(); - final List tracks = []; + 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 String trackId = '${track.groupIndex}_${track.trackIndex}'; + final trackId = '${track.groupIndex}_${track.trackIndex}'; tracks.add( VideoAudioTrack( id: trackId, @@ -271,9 +261,7 @@ class AndroidVideoPlayer extends VideoPlayerPlatform { return player ?? (throw StateError('No active player with ID $id.')); } - PlatformVideoFormat? _platformVideoFormatFromVideoFormat( - VideoFormat? format, - ) { + PlatformVideoFormat? _platformVideoFormatFromVideoFormat(VideoFormat? format) { return switch (format) { VideoFormat.dash => PlatformVideoFormat.dash, VideoFormat.hls => PlatformVideoFormat.hls, @@ -468,9 +456,7 @@ class _PlayerInstance { // should be synchronous with the state change. break; case PlatformPlaybackState.ended: - _eventStreamController.add( - VideoEvent(eventType: VideoEventType.completed), - ); + _eventStreamController.add(VideoEvent(eventType: VideoEventType.completed)); case PlatformPlaybackState.unknown: // Ignore unknown states. This isn't an error since the media // framework could add new states in the future. diff --git a/packages/video_player/video_player_android/test/android_video_player_test.dart b/packages/video_player/video_player_android/test/android_video_player_test.dart index 3b305e64291..b664b177119 100644 --- a/packages/video_player/video_player_android/test/android_video_player_test.dart +++ b/packages/video_player/video_player_android/test/android_video_player_test.dart @@ -63,8 +63,7 @@ void main() { final player = AndroidVideoPlayer( pluginApi: pluginApi, playerApiProvider: (_) => instanceApi, - videoEventStreamProvider: (_) => - streamController.stream.asBroadcastStream(), + videoEventStreamProvider: (_) => streamController.stream.asBroadcastStream(), ); player.ensurePlayerInitialized( playerId, @@ -101,23 +100,17 @@ void main() { final (AndroidVideoPlayer player, MockAndroidVideoPlayerApi api, _) = setUpMockPlayer(playerId: 1, textureId: 100); const newPlayerId = 2; - when(api.createForTextureView(any)).thenAnswer( - (_) async => TexturePlayerIds(playerId: newPlayerId, textureId: 100), - ); + when( + api.createForTextureView(any), + ).thenAnswer((_) async => TexturePlayerIds(playerId: newPlayerId, textureId: 100)); const asset = 'someAsset'; const package = 'somePackage'; const assetKey = 'resultingAssetKey'; - when( - api.getLookupKeyForAsset(asset, package), - ).thenAnswer((_) async => assetKey); + when(api.getLookupKeyForAsset(asset, package)).thenAnswer((_) async => assetKey); final int? playerId = await player.create( - DataSource( - sourceType: DataSourceType.asset, - asset: asset, - package: package, - ), + DataSource(sourceType: DataSourceType.asset, asset: asset, package: package), ); final VerificationResult verification = verify( @@ -136,9 +129,9 @@ void main() { final (AndroidVideoPlayer player, MockAndroidVideoPlayerApi api, _) = setUpMockPlayer(playerId: 1, textureId: 100); const newPlayerId = 2; - when(api.createForTextureView(any)).thenAnswer( - (_) async => TexturePlayerIds(playerId: newPlayerId, textureId: 100), - ); + when( + api.createForTextureView(any), + ).thenAnswer((_) async => TexturePlayerIds(playerId: newPlayerId, textureId: 100)); const uri = 'https://example.com'; final int? playerId = await player.create( @@ -261,11 +254,7 @@ void main() { const fileUri = 'file:///foo/bar'; const headers = {'Authorization': 'Bearer token'}; await player.create( - DataSource( - sourceType: DataSourceType.file, - uri: fileUri, - httpHeaders: headers, - ), + DataSource(sourceType: DataSourceType.file, uri: fileUri, httpHeaders: headers), ); final VerificationResult verification = verify( api.createForTextureView(captureAny), @@ -278,16 +267,14 @@ void main() { final (AndroidVideoPlayer player, MockAndroidVideoPlayerApi api, _) = setUpMockPlayer(playerId: 1, textureId: 100); const newPlayerId = 2; - when(api.createForTextureView(any)).thenAnswer( - (_) async => TexturePlayerIds(playerId: newPlayerId, textureId: 100), - ); + when( + api.createForTextureView(any), + ).thenAnswer((_) async => TexturePlayerIds(playerId: newPlayerId, textureId: 100)); const asset = 'someAsset'; const package = 'somePackage'; const assetKey = 'resultingAssetKey'; - when( - api.getLookupKeyForAsset(asset, package), - ).thenAnswer((_) async => assetKey); + when(api.getLookupKeyForAsset(asset, package)).thenAnswer((_) async => assetKey); final int? playerId = await player.createWithOptions( VideoCreationOptions( @@ -316,9 +303,9 @@ void main() { final (AndroidVideoPlayer player, MockAndroidVideoPlayerApi api, _) = setUpMockPlayer(playerId: 1, textureId: 100); const newPlayerId = 2; - when(api.createForTextureView(any)).thenAnswer( - (_) async => TexturePlayerIds(playerId: newPlayerId, textureId: 100), - ); + when( + api.createForTextureView(any), + ).thenAnswer((_) async => TexturePlayerIds(playerId: newPlayerId, textureId: 100)); const uri = 'https://example.com'; final int? playerId = await player.createWithOptions( @@ -350,9 +337,9 @@ void main() { final (AndroidVideoPlayer player, MockAndroidVideoPlayerApi api, _) = setUpMockPlayer(playerId: 1, textureId: 100); const newPlayerId = 2; - when(api.createForTextureView(any)).thenAnswer( - (_) async => TexturePlayerIds(playerId: newPlayerId, textureId: 100), - ); + when( + api.createForTextureView(any), + ).thenAnswer((_) async => TexturePlayerIds(playerId: newPlayerId, textureId: 100)); const headers = {'Authorization': 'Bearer token'}; final int? playerId = await player.createWithOptions( @@ -378,9 +365,9 @@ void main() { final (AndroidVideoPlayer player, MockAndroidVideoPlayerApi api, _) = setUpMockPlayer(playerId: 1, textureId: 100); const newPlayerId = 2; - when(api.createForTextureView(any)).thenAnswer( - (_) async => TexturePlayerIds(playerId: newPlayerId, textureId: 100), - ); + when( + api.createForTextureView(any), + ).thenAnswer((_) async => TexturePlayerIds(playerId: newPlayerId, textureId: 100)); const fileUri = 'file:///foo/bar'; final int? playerId = await player.createWithOptions( @@ -456,39 +443,24 @@ void main() { }); test('setLooping', () async { - final ( - AndroidVideoPlayer player, - _, - MockVideoPlayerInstanceApi playerApi, - ) = setUpMockPlayer( - playerId: 1, - ); + final (AndroidVideoPlayer player, _, MockVideoPlayerInstanceApi playerApi) = + setUpMockPlayer(playerId: 1); await player.setLooping(1, true); verify(playerApi.setLooping(true)); }); test('play', () async { - final ( - AndroidVideoPlayer player, - _, - MockVideoPlayerInstanceApi playerApi, - ) = setUpMockPlayer( - playerId: 1, - ); + final (AndroidVideoPlayer player, _, MockVideoPlayerInstanceApi playerApi) = + setUpMockPlayer(playerId: 1); await player.play(1); verify(playerApi.play()); }); test('pause', () async { - final ( - AndroidVideoPlayer player, - _, - MockVideoPlayerInstanceApi playerApi, - ) = setUpMockPlayer( - playerId: 1, - ); + final (AndroidVideoPlayer player, _, MockVideoPlayerInstanceApi playerApi) = + setUpMockPlayer(playerId: 1); await player.pause(1); verify(playerApi.pause()); @@ -513,13 +485,8 @@ void main() { }); test('setVolume', () async { - final ( - AndroidVideoPlayer player, - _, - MockVideoPlayerInstanceApi playerApi, - ) = setUpMockPlayer( - playerId: 1, - ); + final (AndroidVideoPlayer player, _, MockVideoPlayerInstanceApi playerApi) = + setUpMockPlayer(playerId: 1); const volume = 0.7; await player.setVolume(1, volume); @@ -527,13 +494,8 @@ void main() { }); test('setPlaybackSpeed', () async { - final ( - AndroidVideoPlayer player, - _, - MockVideoPlayerInstanceApi playerApi, - ) = setUpMockPlayer( - playerId: 1, - ); + final (AndroidVideoPlayer player, _, MockVideoPlayerInstanceApi playerApi) = + setUpMockPlayer(playerId: 1); const speed = 1.5; await player.setPlaybackSpeed(1, speed); @@ -541,34 +503,19 @@ void main() { }); test('seekTo', () async { - final ( - AndroidVideoPlayer player, - _, - MockVideoPlayerInstanceApi playerApi, - ) = setUpMockPlayer( - playerId: 1, - ); + final (AndroidVideoPlayer player, _, MockVideoPlayerInstanceApi playerApi) = + setUpMockPlayer(playerId: 1); const positionMilliseconds = 12345; - await player.seekTo( - 1, - const Duration(milliseconds: positionMilliseconds), - ); + await player.seekTo(1, const Duration(milliseconds: positionMilliseconds)); verify(playerApi.seekTo(positionMilliseconds)); }); test('getPosition', () async { - final ( - AndroidVideoPlayer player, - _, - MockVideoPlayerInstanceApi playerApi, - ) = setUpMockPlayer( - playerId: 1, - ); + final (AndroidVideoPlayer player, _, MockVideoPlayerInstanceApi playerApi) = + setUpMockPlayer(playerId: 1); const positionMilliseconds = 12345; - when( - playerApi.getCurrentPosition(), - ).thenAnswer((_) async => positionMilliseconds); + when(playerApi.getCurrentPosition()).thenAnswer((_) async => positionMilliseconds); final Duration position = await player.getPosition(1); expect(position, const Duration(milliseconds: positionMilliseconds)); @@ -578,9 +525,7 @@ void main() { // Sets up a mock player that emits the given event structure as a success // callback on the internal platform channel event stream, and returns // the player's videoEventsFor(...) stream. - Stream mockPlayerEmitingEvents( - List events, - ) { + Stream mockPlayerEmitingEvents(List events) { const playerId = 1; final ( AndroidVideoPlayer player, @@ -597,15 +542,16 @@ void main() { } test('initialize', () async { - final Stream eventStream = - mockPlayerEmitingEvents([ - InitializationEvent( - duration: 98765, - width: 1920, - height: 1080, - rotationCorrection: 90, - ), - ]); + final Stream eventStream = mockPlayerEmitingEvents( + [ + InitializationEvent( + duration: 98765, + width: 1920, + height: 1080, + rotationCorrection: 90, + ), + ], + ); expect( eventStream, @@ -621,15 +567,16 @@ void main() { }); test('initialization triggers buffer update polling', () async { - final Stream eventStream = - mockPlayerEmitingEvents([ - InitializationEvent( - duration: 98765, - width: 1920, - height: 1080, - rotationCorrection: 90, - ), - ]); + final Stream eventStream = mockPlayerEmitingEvents( + [ + InitializationEvent( + duration: 98765, + width: 1920, + height: 1080, + rotationCorrection: 90, + ), + ], + ); expect( eventStream, @@ -642,9 +589,7 @@ void main() { ), VideoEvent( eventType: VideoEventType.bufferingUpdate, - buffered: [ - DurationRange(Duration.zero, Duration.zero), - ], + buffered: [DurationRange(Duration.zero, Duration.zero)], ), ]), ); @@ -659,9 +604,7 @@ void main() { expect( eventStream, - emitsInOrder([ - VideoEvent(eventType: VideoEventType.completed), - ]), + emitsInOrder([VideoEvent(eventType: VideoEventType.completed)]), ); }); @@ -679,9 +622,7 @@ void main() { // A buffer start should trigger a buffer update as well. VideoEvent( eventType: VideoEventType.bufferingUpdate, - buffered: [ - DurationRange(Duration.zero, Duration.zero), - ], + buffered: [DurationRange(Duration.zero, Duration.zero)], ), ]), ); @@ -704,9 +645,7 @@ void main() { VideoEvent(eventType: VideoEventType.bufferingStart), VideoEvent( eventType: VideoEventType.bufferingUpdate, - buffered: [ - DurationRange(Duration.zero, Duration.zero), - ], + buffered: [DurationRange(Duration.zero, Duration.zero)], ), // Emitted by ready. VideoEvent(eventType: VideoEventType.bufferingEnd), @@ -731,9 +670,7 @@ void main() { VideoEvent(eventType: VideoEventType.bufferingStart), VideoEvent( eventType: VideoEventType.bufferingUpdate, - buffered: [ - DurationRange(Duration.zero, Duration.zero), - ], + buffered: [DurationRange(Duration.zero, Duration.zero)], ), // Emitted by ready. VideoEvent(eventType: VideoEventType.bufferingEnd), @@ -758,9 +695,7 @@ void main() { VideoEvent(eventType: VideoEventType.bufferingStart), VideoEvent( eventType: VideoEventType.bufferingUpdate, - buffered: [ - DurationRange(Duration.zero, Duration.zero), - ], + buffered: [DurationRange(Duration.zero, Duration.zero)], ), // Emitted by ended. VideoEvent(eventType: VideoEventType.completed), @@ -777,10 +712,7 @@ void main() { expect( eventStream, emitsInOrder([ - VideoEvent( - eventType: VideoEventType.isPlayingStateUpdate, - isPlaying: true, - ), + VideoEvent(eventType: VideoEventType.isPlayingStateUpdate, isPlaying: true), ]), ); }); @@ -793,10 +725,7 @@ void main() { expect( eventStream, emitsInOrder([ - VideoEvent( - eventType: VideoEventType.isPlayingStateUpdate, - isPlaying: false, - ), + VideoEvent(eventType: VideoEventType.isPlayingStateUpdate, isPlaying: false), ]), ); }); @@ -813,9 +742,7 @@ void main() { final (AndroidVideoPlayer player, _, MockVideoPlayerInstanceApi api) = setUpMockPlayer(playerId: 1); when(api.getAudioTracks()).thenAnswer( - (_) async => NativeAudioTrackData( - exoPlayerTracks: [], - ), + (_) async => NativeAudioTrackData(exoPlayerTracks: []), ); final List tracks = await player.getAudioTracks(1); @@ -824,66 +751,61 @@ void main() { verify(api.getAudioTracks()); }); - test( - 'getAudioTracks converts native tracks to VideoAudioTrack', - () async { - final (AndroidVideoPlayer player, _, MockVideoPlayerInstanceApi api) = - setUpMockPlayer(playerId: 1); - when(api.getAudioTracks()).thenAnswer( - (_) async => NativeAudioTrackData( - exoPlayerTracks: [ - ExoPlayerAudioTrackData( - groupIndex: 0, - trackIndex: 1, - label: 'English', - language: 'en', - isSelected: true, - bitrate: 128000, - sampleRate: 44100, - channelCount: 2, - codec: 'mp4a.40.2', - ), - ExoPlayerAudioTrackData( - groupIndex: 0, - trackIndex: 2, - label: 'Spanish', - language: 'es', - isSelected: false, - bitrate: 128000, - sampleRate: 44100, - channelCount: 2, - codec: 'mp4a.40.2', - ), - ], - ), - ); - - final List tracks = await player.getAudioTracks(1); - - expect(tracks.length, 2); + test('getAudioTracks converts native tracks to VideoAudioTrack', () async { + final (AndroidVideoPlayer player, _, MockVideoPlayerInstanceApi api) = + setUpMockPlayer(playerId: 1); + when(api.getAudioTracks()).thenAnswer( + (_) async => NativeAudioTrackData( + exoPlayerTracks: [ + ExoPlayerAudioTrackData( + groupIndex: 0, + trackIndex: 1, + label: 'English', + language: 'en', + isSelected: true, + bitrate: 128000, + sampleRate: 44100, + channelCount: 2, + codec: 'mp4a.40.2', + ), + ExoPlayerAudioTrackData( + groupIndex: 0, + trackIndex: 2, + label: 'Spanish', + language: 'es', + isSelected: false, + bitrate: 128000, + sampleRate: 44100, + channelCount: 2, + codec: 'mp4a.40.2', + ), + ], + ), + ); - expect(tracks[0].id, '0_1'); - expect(tracks[0].label, 'English'); - expect(tracks[0].language, 'en'); - expect(tracks[0].isSelected, true); - expect(tracks[0].bitrate, 128000); - expect(tracks[0].sampleRate, 44100); - expect(tracks[0].channelCount, 2); - expect(tracks[0].codec, 'mp4a.40.2'); + final List tracks = await player.getAudioTracks(1); - expect(tracks[1].id, '0_2'); - expect(tracks[1].label, 'Spanish'); - expect(tracks[1].language, 'es'); - expect(tracks[1].isSelected, false); - }, - ); + expect(tracks.length, 2); + + expect(tracks[0].id, '0_1'); + expect(tracks[0].label, 'English'); + expect(tracks[0].language, 'en'); + expect(tracks[0].isSelected, true); + expect(tracks[0].bitrate, 128000); + expect(tracks[0].sampleRate, 44100); + expect(tracks[0].channelCount, 2); + expect(tracks[0].codec, 'mp4a.40.2'); + + expect(tracks[1].id, '0_2'); + expect(tracks[1].label, 'Spanish'); + expect(tracks[1].language, 'es'); + expect(tracks[1].isSelected, false); + }); test('getAudioTracks handles null exoPlayerTracks', () async { final (AndroidVideoPlayer player, _, MockVideoPlayerInstanceApi api) = setUpMockPlayer(playerId: 1); - when( - api.getAudioTracks(), - ).thenAnswer((_) async => NativeAudioTrackData(exoPlayerTracks: null)); + when(api.getAudioTracks()).thenAnswer((_) async => NativeAudioTrackData()); final List tracks = await player.getAudioTracks(1); @@ -921,10 +843,7 @@ void main() { test('selectAudioTrack throws on trackId with too many parts', () async { final (AndroidVideoPlayer player, _, _) = setUpMockPlayer(playerId: 1); - expect( - () => player.selectAudioTrack(1, '1_2_3'), - throwsA(isA()), - ); + expect(() => player.selectAudioTrack(1, '1_2_3'), throwsA(isA())); }); test('selectAudioTrack completes on AudioTrackChangedEvent', () async {