diff --git a/packages/video_player/video_player_android/CHANGELOG.md b/packages/video_player/video_player_android/CHANGELOG.md index 85f40631a278..8e27dfa00867 100644 --- a/packages/video_player/video_player_android/CHANGELOG.md +++ b/packages/video_player/video_player_android/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.10.0 + +* Implements `getVideoTracks()` and `selectVideoTrack()` methods for video track (quality) selection using ExoPlayer. + ## 2.9.5 * Updates build files from Groovy to Kotlin. 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 33988786a78a..c776109a741c 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 @@ -95,8 +95,12 @@ public void onIsPlayingChanged(boolean isPlaying) { @Override public void onTracksChanged(@NonNull Tracks tracks) { // Find the currently selected audio track and notify - String selectedTrackId = findSelectedAudioTrackId(tracks); - events.onAudioTrackChanged(selectedTrackId); + String selectedAudioTrackId = findSelectedAudioTrackId(tracks); + events.onAudioTrackChanged(selectedAudioTrackId); + + // Find the currently selected video track and notify + String selectedVideoTrackId = findSelectedVideoTrackId(tracks); + events.onVideoTrackChanged(selectedVideoTrackId); } /** @@ -121,4 +125,27 @@ private String findSelectedAudioTrackId(@NonNull Tracks tracks) { } return null; } + + /** + * Finds the ID of the currently selected video track. + * + * @param tracks The current tracks + * @return The track ID in format "groupIndex_trackIndex", or null if no video track is selected + */ + @Nullable + private String findSelectedVideoTrackId(@NonNull Tracks tracks) { + int groupIndex = 0; + for (Tracks.Group group : tracks.getGroups()) { + if (group.getType() == C.TRACK_TYPE_VIDEO && 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 7cfb5c1c13be..af7d7e64ddef 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 @@ -93,6 +93,28 @@ private static void setAudioAttributes(ExoPlayer exoPlayer, boolean isMixMode) { !isMixMode); } + /** + * Helper method to extract a long value from a Format field, returning null if the value is + * Format.NO_VALUE. + * + * @param value The format value to check. + * @return The value as a Long, or null if it's Format.NO_VALUE. + */ + private static Long getFormatValue(int value) { + return value != Format.NO_VALUE ? (long) value : null; + } + + /** + * Helper method to extract a double value from a Format field, returning null if the value is + * Format.NO_VALUE. + * + * @param value The format value to check. + * @return The value as a Double, or null if it's Format.NO_VALUE. + */ + private static Double getFormatValue(double value) { + return value != Format.NO_VALUE ? value : null; + } + @Override public void play() { exoPlayer.play(); @@ -170,9 +192,9 @@ public ExoPlayer getExoPlayer() { 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, + getFormatValue(format.bitrate), + getFormatValue(format.sampleRate), + getFormatValue(format.channelCount), format.codecs != null ? format.codecs : null); audioTracks.add(audioTrack); @@ -233,6 +255,188 @@ public void selectAudioTrack(long groupIndex, long trackIndex) { trackSelector.buildUponParameters().setOverrideForType(override).build()); } + // TODO: Migrate to stable API, see https://github.com/flutter/flutter/issues/147039. + @UnstableApi + @Override + public @NonNull NativeVideoTrackData getVideoTracks() { + List videoTracks = 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 video tracks + if (group.getType() == C.TRACK_TYPE_VIDEO) { + for (int trackIndex = 0; trackIndex < group.length; trackIndex++) { + Format format = group.getTrackFormat(trackIndex); + boolean isSelected = group.isTrackSelected(trackIndex); + + // Create video track data with metadata + ExoPlayerVideoTrackData videoTrack = + new ExoPlayerVideoTrackData( + (long) groupIndex, + (long) trackIndex, + format.label, + isSelected, + getFormatValue(format.bitrate), + getFormatValue(format.width), + getFormatValue(format.height), + getFormatValue(format.frameRate), + format.codecs != null ? format.codecs : null); + + videoTracks.add(videoTrack); + } + } + } + return new NativeVideoTrackData(videoTracks); + } + + // TODO: Migrate to stable API, see https://github.com/flutter/flutter/issues/147039. + @UnstableApi + @Override + public void enableAutoVideoQuality() { + if (trackSelector == null) { + throw new IllegalStateException("Cannot enable auto video quality: track selector is null"); + } + + // Clear video track override to enable adaptive streaming + trackSelector.setParameters( + trackSelector.buildUponParameters().clearOverridesOfType(C.TRACK_TYPE_VIDEO).build()); + } + + // TODO: Migrate to stable API, see https://github.com/flutter/flutter/issues/147039. + @UnstableApi + @Override + public void selectVideoTrack(long groupIndex, long trackIndex) { + if (trackSelector == null) { + throw new IllegalStateException("Cannot select video track: track selector is null"); + } + + // Get current tracks + Tracks tracks = exoPlayer.getCurrentTracks(); + + if (groupIndex < 0 || groupIndex >= tracks.getGroups().size()) { + throw new IllegalArgumentException( + "Cannot select video track: groupIndex " + + groupIndex + + " is out of bounds (available groups: " + + tracks.getGroups().size() + + ")"); + } + + Tracks.Group group = tracks.getGroups().get((int) groupIndex); + + // Verify it's a video track + if (group.getType() != C.TRACK_TYPE_VIDEO) { + throw new IllegalArgumentException( + "Cannot select video track: group at index " + + groupIndex + + " is not a video track (type: " + + group.getType() + + ")"); + } + + // Verify the track index is valid + if (trackIndex < 0 || (int) trackIndex >= group.length) { + throw new IllegalArgumentException( + "Cannot select video track: trackIndex " + + trackIndex + + " is out of bounds (available tracks in group: " + + group.length + + ")"); + } + + // Get the track group and create a selection override + TrackGroup trackGroup = group.getMediaTrackGroup(); + TrackSelectionOverride override = new TrackSelectionOverride(trackGroup, (int) trackIndex); + + // Check if the new track has different dimensions than the current track + Format currentFormat = exoPlayer.getVideoFormat(); + Format newFormat = trackGroup.getFormat((int) trackIndex); + boolean dimensionsChanged = + currentFormat != null + && (currentFormat.width != newFormat.width || currentFormat.height != newFormat.height); + + // When video dimensions change, we need to force a complete renderer reset to avoid + // surface rendering issues. We do this by temporarily disabling the video track type, + // which causes ExoPlayer to release the current video renderer and MediaCodec decoder. + // After a brief delay, we re-enable video with the new track selection, which creates + // a fresh renderer properly configured for the new dimensions. + // + // Why is this necessary? + // When switching between video tracks with different resolutions (e.g., 720p to 1080p), + // the existing video surface and MediaCodec decoder may not properly reconfigure for the + // new dimensions. This can cause visual glitches where the video appears in the wrong + // position (e.g., top-left corner) or the old surface remains partially visible. + // By disabling the video track type, we force ExoPlayer to completely release the + // current renderer and decoder, ensuring a clean slate for the new resolution. + // + // References: + // - ExoPlayer TrackSelection documentation: + // https://developer.android.com/media/media3/exoplayer/track-selection + // - DefaultTrackSelector.setParameters() for track type disabling: + // https://developer.android.com/reference/androidx/media3/exoplayer/trackselection/DefaultTrackSelector.Parameters.Builder#setTrackTypeDisabled(int,boolean) + // - This approach is necessary because ExoPlayer doesn't provide a direct API to force + // a renderer reset when dimensions change. Disabling and re-enabling the track type + // is the recommended way to ensure proper resource cleanup and reinitialization. + // TODO(nateshmbhat): Remove this workaround once Media3 provides a supported + // renderer reset path or reliable resolution-changing track switches. + // https://github.com/flutter/flutter/issues/183824 + if (dimensionsChanged) { + final boolean wasPlaying = exoPlayer.isPlaying(); + final long currentPosition = exoPlayer.getCurrentPosition(); + + // Disable video track type to force renderer release + trackSelector.setParameters( + trackSelector + .buildUponParameters() + .setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, true) + .build()); + + // Re-enable video with the new track selection after allowing renderer to release. + // + // Why 150ms delay? + // This delay is necessary to allow the MediaCodec decoder and video renderer to fully + // release their resources before we attempt to create new ones. Without this delay, + // the new decoder may be initialized before the old one is completely released, leading + // to resource conflicts and rendering artifacts. The 150ms value was determined through + // empirical testing across various Android devices and provides a reliable balance + // between responsiveness and ensuring complete resource cleanup. Shorter delays (e.g., + // 50-100ms) were found to still cause glitches on some devices, while longer delays + // would unnecessarily impact user experience. + new android.os.Handler(android.os.Looper.getMainLooper()) + .postDelayed( + () -> { + // Guard against player disposal during the delay + if (trackSelector == null) { + return; + } + + trackSelector.setParameters( + trackSelector + .buildUponParameters() + .setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, false) + .setOverrideForType(override) + .build()); + + // Restore playback state + exoPlayer.seekTo(currentPosition); + if (wasPlaying) { + exoPlayer.play(); + } + }, + 150); + return; + } + + // Apply the track selection override normally if dimensions haven't changed + trackSelector.setParameters( + trackSelector.buildUponParameters().setOverrideForType(override).build()); + } + public void dispose() { if (disposeHandler != null) { disposeHandler.onDispose(); diff --git a/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerCallbacks.java b/packages/video_player/video_player_android/android/src/main/java/io/flutter/plugins/videoplayer/VideoPlayerCallbacks.java index 4cac902319ec..45638321c04b 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 @@ -26,4 +26,6 @@ public interface VideoPlayerCallbacks { void onIsPlayingStateUpdate(boolean isPlaying); void onAudioTrackChanged(@Nullable String selectedTrackId); + + void onVideoTrackChanged(@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 a471ec960e63..21484cc2df36 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 @@ -68,4 +68,9 @@ public void onIsPlayingStateUpdate(boolean isPlaying) { public void onAudioTrackChanged(@Nullable String selectedTrackId) { eventSink.success(new AudioTrackChangedEvent(selectedTrackId)); } + + @Override + public void onVideoTrackChanged(@Nullable String selectedTrackId) { + eventSink.success(new VideoTrackChangedEvent(selectedTrackId)); + } } 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 e546c744e561..e1a73f1bb326 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 @@ -263,6 +263,47 @@ data class AudioTrackChangedEvent( override fun hashCode(): Int = toList().hashCode() } +/** + * Sent when video tracks change. + * + * This includes when the selected video track changes after calling selectVideoTrack. Corresponds + * to ExoPlayer's onTracksChanged. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class VideoTrackChangedEvent( + /** + * The ID of the newly selected video track, if any. Will be null when auto quality selection is + * enabled. + */ + val selectedTrackId: String? = null +) : PlatformVideoEvent() { + companion object { + fun fromList(pigeonVar_list: List): VideoTrackChangedEvent { + val selectedTrackId = pigeonVar_list[0] as String? + return VideoTrackChangedEvent(selectedTrackId) + } + } + + fun toList(): List { + return listOf( + selectedTrackId, + ) + } + + override fun equals(other: Any?): Boolean { + if (other !is VideoTrackChangedEvent) { + 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. * @@ -557,6 +598,100 @@ data class NativeAudioTrackData( override fun hashCode(): Int = toList().hashCode() } +/** + * Raw video track data from ExoPlayer Format objects. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class ExoPlayerVideoTrackData( + val groupIndex: Long, + val trackIndex: Long, + val label: String? = null, + val isSelected: Boolean, + val bitrate: Long? = null, + val width: Long? = null, + val height: Long? = null, + val frameRate: Double? = null, + val codec: String? = null +) { + companion object { + fun fromList(pigeonVar_list: List): ExoPlayerVideoTrackData { + val groupIndex = pigeonVar_list[0] as Long + val trackIndex = pigeonVar_list[1] as Long + val label = pigeonVar_list[2] as String? + val isSelected = pigeonVar_list[3] as Boolean + val bitrate = pigeonVar_list[4] as Long? + val width = pigeonVar_list[5] as Long? + val height = pigeonVar_list[6] as Long? + val frameRate = pigeonVar_list[7] as Double? + val codec = pigeonVar_list[8] as String? + return ExoPlayerVideoTrackData( + groupIndex, trackIndex, label, isSelected, bitrate, width, height, frameRate, codec) + } + } + + fun toList(): List { + return listOf( + groupIndex, + trackIndex, + label, + isSelected, + bitrate, + width, + height, + frameRate, + codec, + ) + } + + override fun equals(other: Any?): Boolean { + if (other !is ExoPlayerVideoTrackData) { + return false + } + if (this === other) { + return true + } + return MessagesPigeonUtils.deepEquals(toList(), other.toList()) + } + + override fun hashCode(): Int = toList().hashCode() +} + +/** + * Container for raw video track data from Android ExoPlayer. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class NativeVideoTrackData( + /** ExoPlayer-based tracks */ + val exoPlayerTracks: List? = null +) { + companion object { + fun fromList(pigeonVar_list: List): NativeVideoTrackData { + val exoPlayerTracks = pigeonVar_list[0] as List? + return NativeVideoTrackData(exoPlayerTracks) + } + } + + fun toList(): List { + return listOf( + exoPlayerTracks, + ) + } + + override fun equals(other: Any?): Boolean { + if (other !is NativeVideoTrackData) { + 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) { @@ -579,28 +714,37 @@ private open class MessagesPigeonCodec : StandardMessageCodec() { return (readValue(buffer) as? List)?.let { AudioTrackChangedEvent.fromList(it) } } 135.toByte() -> { + return (readValue(buffer) as? List)?.let { VideoTrackChangedEvent.fromList(it) } + } + 136.toByte() -> { return (readValue(buffer) as? List)?.let { PlatformVideoViewCreationParams.fromList(it) } } - 136.toByte() -> { + 137.toByte() -> { return (readValue(buffer) as? List)?.let { CreationOptions.fromList(it) } } - 137.toByte() -> { + 138.toByte() -> { return (readValue(buffer) as? List)?.let { TexturePlayerIds.fromList(it) } } - 138.toByte() -> { + 139.toByte() -> { return (readValue(buffer) as? List)?.let { PlaybackState.fromList(it) } } - 139.toByte() -> { + 140.toByte() -> { return (readValue(buffer) as? List)?.let { AudioTrackMessage.fromList(it) } } - 140.toByte() -> { + 141.toByte() -> { return (readValue(buffer) as? List)?.let { ExoPlayerAudioTrackData.fromList(it) } } - 141.toByte() -> { + 142.toByte() -> { return (readValue(buffer) as? List)?.let { NativeAudioTrackData.fromList(it) } } + 143.toByte() -> { + return (readValue(buffer) as? List)?.let { ExoPlayerVideoTrackData.fromList(it) } + } + 144.toByte() -> { + return (readValue(buffer) as? List)?.let { NativeVideoTrackData.fromList(it) } + } else -> super.readValueOfType(type, buffer) } } @@ -631,34 +775,46 @@ private open class MessagesPigeonCodec : StandardMessageCodec() { stream.write(134) writeValue(stream, value.toList()) } - is PlatformVideoViewCreationParams -> { + is VideoTrackChangedEvent -> { stream.write(135) writeValue(stream, value.toList()) } - is CreationOptions -> { + is PlatformVideoViewCreationParams -> { stream.write(136) writeValue(stream, value.toList()) } - is TexturePlayerIds -> { + is CreationOptions -> { stream.write(137) writeValue(stream, value.toList()) } - is PlaybackState -> { + is TexturePlayerIds -> { stream.write(138) writeValue(stream, value.toList()) } - is AudioTrackMessage -> { + is PlaybackState -> { stream.write(139) writeValue(stream, value.toList()) } - is ExoPlayerAudioTrackData -> { + is AudioTrackMessage -> { stream.write(140) writeValue(stream, value.toList()) } - is NativeAudioTrackData -> { + is ExoPlayerAudioTrackData -> { stream.write(141) writeValue(stream, value.toList()) } + is NativeAudioTrackData -> { + stream.write(142) + writeValue(stream, value.toList()) + } + is ExoPlayerVideoTrackData -> { + stream.write(143) + writeValue(stream, value.toList()) + } + is NativeVideoTrackData -> { + stream.write(144) + writeValue(stream, value.toList()) + } else -> super.writeValue(stream, value) } } @@ -854,6 +1010,15 @@ interface VideoPlayerInstanceApi { fun getAudioTracks(): NativeAudioTrackData /** Selects which audio track is chosen for playback from its [groupIndex] and [trackIndex] */ fun selectAudioTrack(groupIndex: Long, trackIndex: Long) + /** Gets the available video tracks for the video. */ + fun getVideoTracks(): NativeVideoTrackData + /** Selects which video track is chosen for playback from its [groupIndex] and [trackIndex]. */ + fun selectVideoTrack(groupIndex: Long, trackIndex: Long) + /** + * Enables automatic video quality selection, allowing the player to adaptively switch between + * available video tracks based on network conditions. + */ + fun enableAutoVideoQuality() companion object { /** The codec used by VideoPlayerInstanceApi. */ @@ -1088,6 +1253,71 @@ interface VideoPlayerInstanceApi { channel.setMessageHandler(null) } } + run { + val channel = + BasicMessageChannel( + binaryMessenger, + "dev.flutter.pigeon.video_player_android.VideoPlayerInstanceApi.getVideoTracks$separatedMessageChannelSuffix", + codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = + try { + listOf(api.getVideoTracks()) + } 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.selectVideoTrack$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.selectVideoTrack(groupIndexArg, trackIndexArg) + listOf(null) + } 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.enableAutoVideoQuality$separatedMessageChannelSuffix", + codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = + try { + api.enableAutoVideoQuality() + 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/VideoPlayerEventCallbacksTest.java b/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoPlayerEventCallbacksTest.java index acb3bfd2b40c..b6eb88c8c30c 100644 --- a/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoPlayerEventCallbacksTest.java +++ b/packages/video_player/video_player_android/android/src/test/java/io/flutter/plugins/videoplayer/VideoPlayerEventCallbacksTest.java @@ -88,4 +88,28 @@ public void onIsPlayingStateUpdate() { IsPlayingStateEvent expected = new IsPlayingStateEvent(true); assertEquals(expected, actual); } + + @Test + public void onAudioTrackChanged() { + String trackId = "0_1"; + eventCallbacks.onAudioTrackChanged(trackId); + + verify(mockEventSink).success(eventCaptor.capture()); + + PlatformVideoEvent actual = eventCaptor.getValue(); + AudioTrackChangedEvent expected = new AudioTrackChangedEvent(trackId); + assertEquals(expected, actual); + } + + @Test + public void onVideoTrackChanged() { + String trackId = "0_2"; + eventCallbacks.onVideoTrackChanged(trackId); + + verify(mockEventSink).success(eventCaptor.capture()); + + PlatformVideoEvent actual = eventCaptor.getValue(); + VideoTrackChangedEvent expected = new VideoTrackChangedEvent(trackId); + assertEquals(expected, actual); + } } 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 92c2ff5f1566..08c9ab49f68b 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 @@ -620,4 +620,401 @@ public void testSelectAudioTrack_negativeIndices() { videoPlayer.dispose(); } + + // ==================== Video Track Tests ==================== + + @Test + public void testGetVideoTracks_withMultipleVideoTracks() { + Tracks mockTracks = mock(Tracks.class); + Tracks.Group mockVideoGroup1 = mock(Tracks.Group.class); + Tracks.Group mockVideoGroup2 = mock(Tracks.Group.class); + Tracks.Group mockAudioGroup = mock(Tracks.Group.class); + + // Create mock formats for video tracks + Format videoFormat1 = + new Format.Builder() + .setId("video_track_1") + .setLabel("1080p") + .setAverageBitrate(5000000) + .setWidth(1920) + .setHeight(1080) + .setFrameRate(30.0f) + .setCodecs("avc1.64001f") + .build(); + + Format videoFormat2 = + new Format.Builder() + .setId("video_track_2") + .setLabel("720p") + .setAverageBitrate(2500000) + .setWidth(1280) + .setHeight(720) + .setFrameRate(24.0f) + .setCodecs("avc1.4d401f") + .build(); + + // Mock video groups and set length field + setGroupLength(mockVideoGroup1, 1); + setGroupLength(mockVideoGroup2, 1); + + when(mockVideoGroup1.getType()).thenReturn(C.TRACK_TYPE_VIDEO); + when(mockVideoGroup1.getTrackFormat(0)).thenReturn(videoFormat1); + when(mockVideoGroup1.isTrackSelected(0)).thenReturn(true); + + when(mockVideoGroup2.getType()).thenReturn(C.TRACK_TYPE_VIDEO); + when(mockVideoGroup2.getTrackFormat(0)).thenReturn(videoFormat2); + when(mockVideoGroup2.isTrackSelected(0)).thenReturn(false); + + when(mockAudioGroup.getType()).thenReturn(C.TRACK_TYPE_AUDIO); + + // Mock tracks + ImmutableList groups = + ImmutableList.of(mockVideoGroup1, mockVideoGroup2, mockAudioGroup); + when(mockTracks.getGroups()).thenReturn(groups); + when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks); + + VideoPlayer videoPlayer = createVideoPlayer(); + + // Test the method + NativeVideoTrackData nativeData = videoPlayer.getVideoTracks(); + List result = nativeData.getExoPlayerTracks(); + + // Verify results + assertNotNull(result); + assertEquals(2, result.size()); + + // Verify first track + ExoPlayerVideoTrackData track1 = result.get(0); + assertEquals(0L, track1.getGroupIndex()); + assertEquals(0L, track1.getTrackIndex()); + assertEquals("1080p", track1.getLabel()); + assertTrue(track1.isSelected()); + assertEquals(Long.valueOf(5000000), track1.getBitrate()); + assertEquals(Long.valueOf(1920), track1.getWidth()); + assertEquals(Long.valueOf(1080), track1.getHeight()); + assertEquals(Double.valueOf(30.0), track1.getFrameRate()); + assertEquals("avc1.64001f", track1.getCodec()); + + // Verify second track + ExoPlayerVideoTrackData track2 = result.get(1); + assertEquals(1L, track2.getGroupIndex()); + assertEquals(0L, track2.getTrackIndex()); + assertEquals("720p", track2.getLabel()); + assertFalse(track2.isSelected()); + assertEquals(Long.valueOf(2500000), track2.getBitrate()); + assertEquals(Long.valueOf(1280), track2.getWidth()); + assertEquals(Long.valueOf(720), track2.getHeight()); + assertEquals(Double.valueOf(24.0), track2.getFrameRate()); + assertEquals("avc1.4d401f", track2.getCodec()); + + videoPlayer.dispose(); + } + + @Test + public void testGetVideoTracks_withNoVideoTracks() { + Tracks mockTracks = mock(Tracks.class); + Tracks.Group mockAudioGroup = mock(Tracks.Group.class); + + // Mock audio group only (no video tracks) + when(mockAudioGroup.getType()).thenReturn(C.TRACK_TYPE_AUDIO); + + ImmutableList groups = ImmutableList.of(mockAudioGroup); + when(mockTracks.getGroups()).thenReturn(groups); + when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks); + + VideoPlayer videoPlayer = createVideoPlayer(); + + // Test the method + NativeVideoTrackData nativeData = videoPlayer.getVideoTracks(); + List result = nativeData.getExoPlayerTracks(); + + // Verify results + assertNotNull(result); + assertEquals(0, result.size()); + + videoPlayer.dispose(); + } + + @Test + public void testGetVideoTracks_withNullValues() { + Tracks mockTracks = mock(Tracks.class); + Tracks.Group mockVideoGroup = mock(Tracks.Group.class); + + // Create format with null/missing values + Format videoFormat = + new Format.Builder() + .setId("video_track_null") + .setLabel(null) // Null label + .setAverageBitrate(Format.NO_VALUE) // No bitrate + .setWidth(Format.NO_VALUE) // No width + .setHeight(Format.NO_VALUE) // No height + .setFrameRate(Format.NO_VALUE) // No frame rate + .setCodecs(null) // Null codec + .build(); + + // Mock video group and set length field + setGroupLength(mockVideoGroup, 1); + when(mockVideoGroup.getType()).thenReturn(C.TRACK_TYPE_VIDEO); + when(mockVideoGroup.getTrackFormat(0)).thenReturn(videoFormat); + when(mockVideoGroup.isTrackSelected(0)).thenReturn(false); + + ImmutableList groups = ImmutableList.of(mockVideoGroup); + when(mockTracks.getGroups()).thenReturn(groups); + when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks); + + VideoPlayer videoPlayer = createVideoPlayer(); + + // Test the method + NativeVideoTrackData nativeData = videoPlayer.getVideoTracks(); + List result = nativeData.getExoPlayerTracks(); + + // Verify results + assertNotNull(result); + assertEquals(1, result.size()); + + ExoPlayerVideoTrackData track = result.get(0); + assertEquals(0L, track.getGroupIndex()); + assertEquals(0L, track.getTrackIndex()); + assertNull(track.getLabel()); // Null values should be preserved + assertFalse(track.isSelected()); + assertNull(track.getBitrate()); + assertNull(track.getWidth()); + assertNull(track.getHeight()); + assertNull(track.getFrameRate()); + assertNull(track.getCodec()); + + videoPlayer.dispose(); + } + + @Test + public void testGetVideoTracks_withMultipleTracksInSameGroup() { + Tracks mockTracks = mock(Tracks.class); + Tracks.Group mockVideoGroup = mock(Tracks.Group.class); + + // Create formats for group with multiple tracks (adaptive streaming scenario) + Format videoFormat1 = + new Format.Builder() + .setId("video_track_1") + .setLabel("1080p") + .setWidth(1920) + .setHeight(1080) + .setAverageBitrate(5000000) + .build(); + + Format videoFormat2 = + new Format.Builder() + .setId("video_track_2") + .setLabel("720p") + .setWidth(1280) + .setHeight(720) + .setAverageBitrate(2500000) + .build(); + + // Mock video group with multiple tracks + setGroupLength(mockVideoGroup, 2); + when(mockVideoGroup.getType()).thenReturn(C.TRACK_TYPE_VIDEO); + when(mockVideoGroup.getTrackFormat(0)).thenReturn(videoFormat1); + when(mockVideoGroup.getTrackFormat(1)).thenReturn(videoFormat2); + when(mockVideoGroup.isTrackSelected(0)).thenReturn(true); + when(mockVideoGroup.isTrackSelected(1)).thenReturn(false); + + ImmutableList groups = ImmutableList.of(mockVideoGroup); + when(mockTracks.getGroups()).thenReturn(groups); + when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks); + + VideoPlayer videoPlayer = createVideoPlayer(); + + // Test the method + NativeVideoTrackData nativeData = videoPlayer.getVideoTracks(); + List result = nativeData.getExoPlayerTracks(); + + // Verify results + assertNotNull(result); + assertEquals(2, result.size()); + + // Verify track indices are correct + ExoPlayerVideoTrackData track1 = result.get(0); + ExoPlayerVideoTrackData 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 testSelectVideoTrack_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 mockVideoGroup = mock(Tracks.Group.class); + + Format videoFormat = + new Format.Builder() + .setId("video_track_1") + .setLabel("1080p") + .setWidth(1920) + .setHeight(1080) + .build(); + + // Create a real TrackGroup with the format + TrackGroup trackGroup = new TrackGroup(videoFormat); + + // Mock video group with 2 tracks + setGroupLength(mockVideoGroup, 2); + when(mockVideoGroup.getType()).thenReturn(C.TRACK_TYPE_VIDEO); + when(mockVideoGroup.getMediaTrackGroup()).thenReturn(trackGroup); + + ImmutableList groups = ImmutableList.of(mockVideoGroup); + when(mockTracks.getGroups()).thenReturn(groups); + + // Set up track selector BEFORE creating VideoPlayer + when(mockExoPlayer.getTrackSelector()).thenReturn(mockTrackSelector); + when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks); + when(mockExoPlayer.getVideoFormat()).thenReturn(videoFormat); + when(mockTrackSelector.buildUponParameters()).thenReturn(mockBuilder); + when(mockBuilder.setOverrideForType(any(TrackSelectionOverride.class))).thenReturn(mockBuilder); + when(mockBuilder.setTrackTypeDisabled(anyInt(), anyBoolean())).thenReturn(mockBuilder); + when(mockBuilder.build()).thenReturn(mockParameters); + + VideoPlayer videoPlayer = createVideoPlayer(); + + // Test selecting a valid video track + videoPlayer.selectVideoTrack(0, 0); + + // Verify track selector was called + verify(mockTrackSelector, atLeastOnce()).buildUponParameters(); + verify(mockBuilder, atLeastOnce()).build(); + verify(mockTrackSelector, atLeastOnce()).setParameters(mockParameters); + + videoPlayer.dispose(); + } + + @Test + public void testSelectVideoTrack_nullTrackSelector() { + // Track selector is null by default in mock + VideoPlayer videoPlayer = createVideoPlayer(); + + assertThrows(IllegalStateException.class, () -> videoPlayer.selectVideoTrack(0, 0)); + + videoPlayer.dispose(); + } + + @Test + public void testSelectVideoTrack_invalidGroupIndex() { + DefaultTrackSelector mockTrackSelector = mock(DefaultTrackSelector.class); + Tracks mockTracks = mock(Tracks.class); + Tracks.Group mockVideoGroup = mock(Tracks.Group.class); + + ImmutableList groups = ImmutableList.of(mockVideoGroup); + when(mockTracks.getGroups()).thenReturn(groups); + when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks); + when(mockExoPlayer.getTrackSelector()).thenReturn(mockTrackSelector); + + VideoPlayer videoPlayer = createVideoPlayer(); + + // Test with invalid group index (only 1 group exists at index 0) + assertThrows(IllegalArgumentException.class, () -> videoPlayer.selectVideoTrack(5, 0)); + + videoPlayer.dispose(); + } + + @Test + public void testSelectVideoTrack_invalidTrackIndex() { + DefaultTrackSelector mockTrackSelector = mock(DefaultTrackSelector.class); + Tracks mockTracks = mock(Tracks.class); + Tracks.Group mockVideoGroup = mock(Tracks.Group.class); + + // Mock video group with only 1 track + 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 with invalid track index (only 1 track exists at index 0) + assertThrows(IllegalArgumentException.class, () -> videoPlayer.selectVideoTrack(0, 5)); + + videoPlayer.dispose(); + } + + @Test + public void testSelectVideoTrack_nonVideoGroup() { + DefaultTrackSelector mockTrackSelector = mock(DefaultTrackSelector.class); + Tracks mockTracks = mock(Tracks.class); + Tracks.Group mockAudioGroup = mock(Tracks.Group.class); + + // Mock audio group (not video) + 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 selecting from a non-video group + assertThrows(IllegalArgumentException.class, () -> videoPlayer.selectVideoTrack(0, 0)); + + videoPlayer.dispose(); + } + + @Test + public void testSelectVideoTrack_negativeIndices() { + DefaultTrackSelector mockTrackSelector = mock(DefaultTrackSelector.class); + Tracks mockTracks = mock(Tracks.class); + Tracks.Group mockVideoGroup = mock(Tracks.Group.class); + + ImmutableList groups = ImmutableList.of(mockVideoGroup); + when(mockTracks.getGroups()).thenReturn(groups); + when(mockExoPlayer.getCurrentTracks()).thenReturn(mockTracks); + when(mockExoPlayer.getTrackSelector()).thenReturn(mockTrackSelector); + + VideoPlayer videoPlayer = createVideoPlayer(); + + // Test with negative group index only (not both -1) + assertThrows(IllegalArgumentException.class, () -> videoPlayer.selectVideoTrack(-1, 0)); + + videoPlayer.dispose(); + } + + @Test + public void testEnableAutoVideoQuality() { + DefaultTrackSelector mockTrackSelector = mock(DefaultTrackSelector.class); + DefaultTrackSelector.Parameters mockParameters = mock(DefaultTrackSelector.Parameters.class); + DefaultTrackSelector.Parameters.Builder mockBuilder = + mock(DefaultTrackSelector.Parameters.Builder.class); + + // Set up track selector + when(mockExoPlayer.getTrackSelector()).thenReturn(mockTrackSelector); + when(mockTrackSelector.buildUponParameters()).thenReturn(mockBuilder); + when(mockBuilder.clearOverridesOfType(C.TRACK_TYPE_VIDEO)).thenReturn(mockBuilder); + when(mockBuilder.build()).thenReturn(mockParameters); + + VideoPlayer videoPlayer = createVideoPlayer(); + + // Test enabling auto quality + videoPlayer.enableAutoVideoQuality(); + + // Verify track selector cleared video overrides + verify(mockTrackSelector).buildUponParameters(); + verify(mockBuilder).clearOverridesOfType(C.TRACK_TYPE_VIDEO); + verify(mockBuilder).build(); + verify(mockTrackSelector).setParameters(mockParameters); + + videoPlayer.dispose(); + } } diff --git a/packages/video_player/video_player_android/example/pubspec.yaml b/packages/video_player/video_player_android/example/pubspec.yaml index 07c5b497d5d2..ae5ccd07734d 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.6.0 + video_player_platform_interface: ^6.7.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 84249bd41afd..803308e5a218 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 @@ -266,6 +266,53 @@ class AndroidVideoPlayer extends VideoPlayerPlatform { return true; } + @override + Future> getVideoTracks(int playerId) async { + final NativeVideoTrackData nativeData = await _playerWith( + id: playerId, + ).getVideoTracks(); + final tracks = []; + + // Convert ExoPlayer tracks to VideoTrack + if (nativeData.exoPlayerTracks != null) { + for (final ExoPlayerVideoTrackData track in nativeData.exoPlayerTracks!) { + // Construct a string ID from groupIndex and trackIndex for compatibility + final trackId = '${track.groupIndex}_${track.trackIndex}'; + // Generate label from resolution if not provided + final String? label = + track.label ?? + (track.width != null && track.height != null + ? '${track.height}p' + : null); + tracks.add( + VideoTrack( + id: trackId, + isSelected: track.isSelected, + label: label, + bitrate: track.bitrate, + width: track.width, + height: track.height, + frameRate: track.frameRate, + codec: track.codec, + ), + ); + } + } + + return tracks; + } + + @override + Future selectVideoTrack(int playerId, VideoTrack? track) { + return _playerWith(id: playerId).selectVideoTrack(track); + } + + @override + bool isVideoTrackSupportAvailable() { + // Android with ExoPlayer supports video track selection + return true; + } + _PlayerInstance _playerWith({required int id}) { final _PlayerInstance? player = _players[id]; return player ?? (throw StateError('No active player with ID $id.')); @@ -314,6 +361,8 @@ class _PlayerInstance { int _lastBufferPosition = -1; bool _isBuffering = false; Completer? _audioTrackSelectionCompleter; + Completer? _videoTrackSelectionCompleter; + String? _expectedVideoTrackId; final VideoPlayerViewState viewState; @@ -384,6 +433,63 @@ class _PlayerInstance { } } + Future getVideoTracks() { + return _api.getVideoTracks(); + } + + Future selectVideoTrack(VideoTrack? track) async { + // Create a completer to wait for the track selection to complete + _videoTrackSelectionCompleter = Completer(); + + if (track == null) { + // Auto quality - use dedicated method + _expectedVideoTrackId = null; + try { + await _api.enableAutoVideoQuality(); + + // Wait for the onTracksChanged event from ExoPlayer with a timeout + await _videoTrackSelectionCompleter!.future.timeout( + const Duration(seconds: 5), + onTimeout: () { + // If we timeout, just continue - the track may still have been selected + }, + ); + } finally { + _videoTrackSelectionCompleter = null; + _expectedVideoTrackId = null; + } + return; + } + + // Extract groupIndex and trackIndex from the track id + final List parts = track.id.split('_'); + if (parts.length != 2) { + throw ArgumentError( + 'Invalid track id format: "${track.id}". Expected format: "groupIndex_trackIndex"', + ); + } + + final int groupIndex = int.parse(parts[0]); + final int trackIndex = int.parse(parts[1]); + + _expectedVideoTrackId = track.id; + + try { + await _api.selectVideoTrack(groupIndex, trackIndex); + + // Wait for the onTracksChanged event from ExoPlayer with a timeout + await _videoTrackSelectionCompleter!.future.timeout( + const Duration(seconds: 5), + onTimeout: () { + // If we timeout, just continue - the track may still have been selected + }, + ); + } finally { + _videoTrackSelectionCompleter = null; + _expectedVideoTrackId = null; + } + } + Future dispose() async { _isDisposed = true; _bufferPollingTimer?.cancel(); @@ -487,6 +593,19 @@ class _PlayerInstance { !_audioTrackSelectionCompleter!.isCompleted) { _audioTrackSelectionCompleter!.complete(); } + case VideoTrackChangedEvent _: + // Complete the video track selection completer only if: + // 1. A completer exists (we're waiting for a selection) + // 2. The completer hasn't already completed + // 3. The selected track ID matches what we're expecting (or we're expecting null for auto) + if (_videoTrackSelectionCompleter != null && + !_videoTrackSelectionCompleter!.isCompleted) { + // Complete if the track ID matches our expectation, or if we expected null (auto mode) + if (_expectedVideoTrackId == null || + event.selectedTrackId == _expectedVideoTrackId) { + _videoTrackSelectionCompleter!.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 1aca7dc531dd..f636d5ece295 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 @@ -218,6 +218,47 @@ class AudioTrackChangedEvent extends PlatformVideoEvent { int get hashCode => Object.hashAll(_toList()); } +/// Sent when video tracks change. +/// +/// This includes when the selected video track changes after calling selectVideoTrack. +/// Corresponds to ExoPlayer's onTracksChanged. +class VideoTrackChangedEvent extends PlatformVideoEvent { + VideoTrackChangedEvent({this.selectedTrackId}); + + /// The ID of the newly selected video track, if any. + /// Will be null when auto quality selection is enabled. + String? selectedTrackId; + + List _toList() { + return [selectedTrackId]; + } + + Object encode() { + return _toList(); + } + + static VideoTrackChangedEvent decode(Object result) { + result as List; + return VideoTrackChangedEvent(selectedTrackId: result[0] as String?); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! VideoTrackChangedEvent || 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}); @@ -588,6 +629,128 @@ class NativeAudioTrackData { int get hashCode => Object.hashAll(_toList()); } +/// Raw video track data from ExoPlayer Format objects. +class ExoPlayerVideoTrackData { + ExoPlayerVideoTrackData({ + required this.groupIndex, + required this.trackIndex, + this.label, + required this.isSelected, + this.bitrate, + this.width, + this.height, + this.frameRate, + this.codec, + }); + + int groupIndex; + + int trackIndex; + + String? label; + + bool isSelected; + + int? bitrate; + + int? width; + + int? height; + + double? frameRate; + + String? codec; + + List _toList() { + return [ + groupIndex, + trackIndex, + label, + isSelected, + bitrate, + width, + height, + frameRate, + codec, + ]; + } + + Object encode() { + return _toList(); + } + + static ExoPlayerVideoTrackData decode(Object result) { + result as List; + return ExoPlayerVideoTrackData( + groupIndex: result[0]! as int, + trackIndex: result[1]! as int, + label: result[2] as String?, + isSelected: result[3]! as bool, + bitrate: result[4] as int?, + width: result[5] as int?, + height: result[6] as int?, + frameRate: result[7] as double?, + codec: result[8] as String?, + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! ExoPlayerVideoTrackData || 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 video track data from Android ExoPlayer. +class NativeVideoTrackData { + NativeVideoTrackData({this.exoPlayerTracks}); + + /// ExoPlayer-based tracks + List? exoPlayerTracks; + + List _toList() { + return [exoPlayerTracks]; + } + + Object encode() { + return _toList(); + } + + static NativeVideoTrackData decode(Object result) { + result as List; + return NativeVideoTrackData( + exoPlayerTracks: (result[0] as List?) + ?.cast(), + ); + } + + @override + // ignore: avoid_equals_and_hash_code_on_mutable_classes + bool operator ==(Object other) { + if (other is! NativeVideoTrackData || 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 @@ -613,27 +776,36 @@ class _PigeonCodec extends StandardMessageCodec { } else if (value is AudioTrackChangedEvent) { buffer.putUint8(134); writeValue(buffer, value.encode()); - } else if (value is PlatformVideoViewCreationParams) { + } else if (value is VideoTrackChangedEvent) { buffer.putUint8(135); writeValue(buffer, value.encode()); - } else if (value is CreationOptions) { + } else if (value is PlatformVideoViewCreationParams) { buffer.putUint8(136); writeValue(buffer, value.encode()); - } else if (value is TexturePlayerIds) { + } else if (value is CreationOptions) { buffer.putUint8(137); writeValue(buffer, value.encode()); - } else if (value is PlaybackState) { + } else if (value is TexturePlayerIds) { buffer.putUint8(138); writeValue(buffer, value.encode()); - } else if (value is AudioTrackMessage) { + } else if (value is PlaybackState) { buffer.putUint8(139); writeValue(buffer, value.encode()); - } else if (value is ExoPlayerAudioTrackData) { + } else if (value is AudioTrackMessage) { buffer.putUint8(140); writeValue(buffer, value.encode()); - } else if (value is NativeAudioTrackData) { + } else if (value is ExoPlayerAudioTrackData) { buffer.putUint8(141); writeValue(buffer, value.encode()); + } else if (value is NativeAudioTrackData) { + buffer.putUint8(142); + writeValue(buffer, value.encode()); + } else if (value is ExoPlayerVideoTrackData) { + buffer.putUint8(143); + writeValue(buffer, value.encode()); + } else if (value is NativeVideoTrackData) { + buffer.putUint8(144); + writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); } @@ -657,19 +829,25 @@ class _PigeonCodec extends StandardMessageCodec { case 134: return AudioTrackChangedEvent.decode(readValue(buffer)!); case 135: - return PlatformVideoViewCreationParams.decode(readValue(buffer)!); + return VideoTrackChangedEvent.decode(readValue(buffer)!); case 136: - return CreationOptions.decode(readValue(buffer)!); + return PlatformVideoViewCreationParams.decode(readValue(buffer)!); case 137: - return TexturePlayerIds.decode(readValue(buffer)!); + return CreationOptions.decode(readValue(buffer)!); case 138: - return PlaybackState.decode(readValue(buffer)!); + return TexturePlayerIds.decode(readValue(buffer)!); case 139: - return AudioTrackMessage.decode(readValue(buffer)!); + return PlaybackState.decode(readValue(buffer)!); case 140: - return ExoPlayerAudioTrackData.decode(readValue(buffer)!); + return AudioTrackMessage.decode(readValue(buffer)!); case 141: + return ExoPlayerAudioTrackData.decode(readValue(buffer)!); + case 142: return NativeAudioTrackData.decode(readValue(buffer)!); + case 143: + return ExoPlayerVideoTrackData.decode(readValue(buffer)!); + case 144: + return NativeVideoTrackData.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); } @@ -1142,6 +1320,86 @@ class VideoPlayerInstanceApi { return; } } + + /// Gets the available video tracks for the video. + Future getVideoTracks() async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.video_player_android.VideoPlayerInstanceApi.getVideoTracks$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final 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 NativeVideoTrackData?)!; + } + } + + /// Selects which video track is chosen for playback from its [groupIndex] and [trackIndex]. + Future selectVideoTrack(int groupIndex, int trackIndex) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.video_player_android.VideoPlayerInstanceApi.selectVideoTrack$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [groupIndex, trackIndex], + ); + final 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; + } + } + + /// Enables automatic video quality selection, allowing the player to adaptively + /// switch between available video tracks based on network conditions. + Future enableAutoVideoQuality() async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.video_player_android.VideoPlayerInstanceApi.enableAutoVideoQuality$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final 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 8666b074969a..be622cf7b75f 100644 --- a/packages/video_player/video_player_android/pigeons/messages.dart +++ b/packages/video_player/video_player_android/pigeons/messages.dart @@ -60,6 +60,16 @@ class AudioTrackChangedEvent extends PlatformVideoEvent { late final String? selectedTrackId; } +/// Sent when video tracks change. +/// +/// This includes when the selected video track changes after calling selectVideoTrack. +/// Corresponds to ExoPlayer's onTracksChanged. +class VideoTrackChangedEvent extends PlatformVideoEvent { + /// The ID of the newly selected video track, if any. + /// Will be null when auto quality selection is enabled. + late final String? selectedTrackId; +} + /// Information passed to the platform view creation. class PlatformVideoViewCreationParams { const PlatformVideoViewCreationParams({required this.playerId}); @@ -148,6 +158,39 @@ class NativeAudioTrackData { List? exoPlayerTracks; } +/// Raw video track data from ExoPlayer Format objects. +class ExoPlayerVideoTrackData { + ExoPlayerVideoTrackData({ + required this.groupIndex, + required this.trackIndex, + this.label, + required this.isSelected, + this.bitrate, + this.width, + this.height, + this.frameRate, + this.codec, + }); + + int groupIndex; + int trackIndex; + String? label; + bool isSelected; + int? bitrate; + int? width; + int? height; + double? frameRate; + String? codec; +} + +/// Container for raw video track data from Android ExoPlayer. +class NativeVideoTrackData { + NativeVideoTrackData({this.exoPlayerTracks}); + + /// ExoPlayer-based tracks + List? exoPlayerTracks; +} + @HostApi() abstract class AndroidVideoPlayerApi { void initialize(); @@ -192,6 +235,16 @@ abstract class VideoPlayerInstanceApi { /// Selects which audio track is chosen for playback from its [groupIndex] and [trackIndex] void selectAudioTrack(int groupIndex, int trackIndex); + + /// Gets the available video tracks for the video. + NativeVideoTrackData getVideoTracks(); + + /// Selects which video track is chosen for playback from its [groupIndex] and [trackIndex]. + void selectVideoTrack(int groupIndex, int trackIndex); + + /// Enables automatic video quality selection, allowing the player to adaptively + /// switch between available video tracks based on network conditions. + void enableAutoVideoQuality(); } @EventChannelApi() diff --git a/packages/video_player/video_player_android/pubspec.yaml b/packages/video_player/video_player_android/pubspec.yaml index 4461ddd73d1e..2ada2a2cc9e0 100644 --- a/packages/video_player/video_player_android/pubspec.yaml +++ b/packages/video_player/video_player_android/pubspec.yaml @@ -2,7 +2,7 @@ name: video_player_android description: Android implementation of the video_player plugin. repository: https://github.com/flutter/packages/tree/main/packages/video_player/video_player_android issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+video_player%22 -version: 2.9.5 +version: 2.10.0 environment: sdk: ^3.9.0 @@ -20,7 +20,7 @@ flutter: dependencies: flutter: sdk: flutter - video_player_platform_interface: ^6.6.0 + video_player_platform_interface: ^6.7.0 dev_dependencies: build_runner: ^2.3.3 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 810a815fddf5..3ae18dc48d46 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 @@ -31,6 +31,15 @@ void main() { NativeAudioTrackData(exoPlayerTracks: []), ), ); + // Provide dummy values for video track types + provideDummy( + NativeVideoTrackData(exoPlayerTracks: []), + ); + provideDummy>( + Future.value( + NativeVideoTrackData(exoPlayerTracks: []), + ), + ); provideDummy>(Future.value()); (AndroidVideoPlayer, MockAndroidVideoPlayerApi, MockVideoPlayerInstanceApi) @@ -950,5 +959,160 @@ void main() { verify(api.selectAudioTrack(0, 1)); }); }); + + group('video tracks', () { + test('isVideoTrackSupportAvailable returns true', () { + final (AndroidVideoPlayer player, _, _) = setUpMockPlayer(playerId: 1); + + expect(player.isVideoTrackSupportAvailable(), true); + }); + + test('getVideoTracks returns empty list when no tracks', () async { + final (AndroidVideoPlayer player, _, MockVideoPlayerInstanceApi api) = + setUpMockPlayer(playerId: 1); + when( + api.getVideoTracks(), + ).thenAnswer((_) async => NativeVideoTrackData()); + + final List tracks = await player.getVideoTracks(1); + + expect(tracks, isEmpty); + }); + + test('getVideoTracks converts native tracks to VideoTrack', () async { + final (AndroidVideoPlayer player, _, MockVideoPlayerInstanceApi api) = + setUpMockPlayer(playerId: 1); + when(api.getVideoTracks()).thenAnswer( + (_) async => NativeVideoTrackData( + exoPlayerTracks: [ + ExoPlayerVideoTrackData( + groupIndex: 0, + trackIndex: 0, + label: '1080p', + isSelected: true, + bitrate: 5000000, + width: 1920, + height: 1080, + frameRate: 30.0, + codec: 'avc1.64001f', + ), + ExoPlayerVideoTrackData( + groupIndex: 0, + trackIndex: 1, + label: '720p', + isSelected: false, + bitrate: 2500000, + width: 1280, + height: 720, + frameRate: 30.0, + codec: 'avc1.64001f', + ), + ], + ), + ); + + final List tracks = await player.getVideoTracks(1); + + expect(tracks.length, 2); + + expect(tracks[0].id, '0_0'); + expect(tracks[0].label, '1080p'); + expect(tracks[0].isSelected, true); + expect(tracks[0].bitrate, 5000000); + expect(tracks[0].width, 1920); + expect(tracks[0].height, 1080); + expect(tracks[0].frameRate, 30.0); + expect(tracks[0].codec, 'avc1.64001f'); + + expect(tracks[1].id, '0_1'); + expect(tracks[1].label, '720p'); + expect(tracks[1].isSelected, false); + expect(tracks[1].bitrate, 2500000); + expect(tracks[1].width, 1280); + expect(tracks[1].height, 720); + }); + + test( + 'getVideoTracks generates label from resolution if not provided', + () async { + final (AndroidVideoPlayer player, _, MockVideoPlayerInstanceApi api) = + setUpMockPlayer(playerId: 1); + when(api.getVideoTracks()).thenAnswer( + (_) async => NativeVideoTrackData( + exoPlayerTracks: [ + ExoPlayerVideoTrackData( + groupIndex: 0, + trackIndex: 0, + isSelected: true, + width: 1920, + height: 1080, + ), + ], + ), + ); + + final List tracks = await player.getVideoTracks(1); + + expect(tracks.length, 1); + expect(tracks[0].label, '1080p'); + }, + ); + + test('getVideoTracks handles null exoPlayerTracks', () async { + final (AndroidVideoPlayer player, _, MockVideoPlayerInstanceApi api) = + setUpMockPlayer(playerId: 1); + when( + api.getVideoTracks(), + ).thenAnswer((_) async => NativeVideoTrackData()); + + final List tracks = await player.getVideoTracks(1); + + expect(tracks, isEmpty); + }); + + test( + 'selectVideoTrack with null clears override (auto quality)', + () async { + final (AndroidVideoPlayer player, _, MockVideoPlayerInstanceApi api) = + setUpMockPlayer(playerId: 1); + when(api.enableAutoVideoQuality()).thenAnswer((_) async {}); + + await player.selectVideoTrack(1, null); + + verify(api.enableAutoVideoQuality()); + }, + ); + + test('selectVideoTrack parses track id and calls API', () async { + final (AndroidVideoPlayer player, _, MockVideoPlayerInstanceApi api) = + setUpMockPlayer(playerId: 1); + when(api.selectVideoTrack(0, 2)).thenAnswer((_) async {}); + + const track = VideoTrack(id: '0_2', isSelected: false); + await player.selectVideoTrack(1, track); + + verify(api.selectVideoTrack(0, 2)); + }); + + test('selectVideoTrack throws on invalid track id format', () async { + final (AndroidVideoPlayer player, _, _) = setUpMockPlayer(playerId: 1); + + const track = VideoTrack(id: 'invalid', isSelected: false); + expect( + () => player.selectVideoTrack(1, track), + throwsA(isA()), + ); + }); + + test('selectVideoTrack throws on track id with too many parts', () async { + final (AndroidVideoPlayer player, _, _) = setUpMockPlayer(playerId: 1); + + const track = VideoTrack(id: '1_2_3', isSelected: false); + expect( + () => player.selectVideoTrack(1, track), + throwsA(isA()), + ); + }); + }); }); } 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 212c9bde40c1..5714d8de6c3b 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 @@ -22,6 +22,7 @@ import 'package:video_player_android/src/messages.g.dart' as _i2; // ignore_for_file: unnecessary_parenthesis // ignore_for_file: camel_case_types // ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member class _FakeTexturePlayerIds_0 extends _i1.SmartFake implements _i2.TexturePlayerIds { @@ -35,6 +36,12 @@ class _FakeNativeAudioTrackData_1 extends _i1.SmartFake : super(parent, parentInvocation); } +class _FakeNativeVideoTrackData_2 extends _i1.SmartFake + implements _i2.NativeVideoTrackData { + _FakeNativeVideoTrackData_2(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + /// A class which mocks [AndroidVideoPlayerApi]. /// /// See the documentation for Mockito's code generation for more information. @@ -252,4 +259,42 @@ class MockVideoPlayerInstanceApi extends _i1.Mock returnValueForMissingStub: _i4.Future.value(), ) as _i4.Future); + + @override + _i4.Future<_i2.NativeVideoTrackData> getVideoTracks() => + (super.noSuchMethod( + Invocation.method(#getVideoTracks, []), + returnValue: _i4.Future<_i2.NativeVideoTrackData>.value( + _FakeNativeVideoTrackData_2( + this, + Invocation.method(#getVideoTracks, []), + ), + ), + returnValueForMissingStub: + _i4.Future<_i2.NativeVideoTrackData>.value( + _FakeNativeVideoTrackData_2( + this, + Invocation.method(#getVideoTracks, []), + ), + ), + ) + as _i4.Future<_i2.NativeVideoTrackData>); + + @override + _i4.Future selectVideoTrack(int? groupIndex, int? trackIndex) => + (super.noSuchMethod( + Invocation.method(#selectVideoTrack, [groupIndex, trackIndex]), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); + + @override + _i4.Future enableAutoVideoQuality() => + (super.noSuchMethod( + Invocation.method(#enableAutoVideoQuality, []), + returnValue: _i4.Future.value(), + returnValueForMissingStub: _i4.Future.value(), + ) + as _i4.Future); }