-
Notifications
You must be signed in to change notification settings - Fork 3.7k
[video_player_android] Add video track selection support #11475
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<ExoPlayerVideoTrackData> 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; | ||
| } | ||
|
Comment on lines
+390
to
+435
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The 150ms delay workaround for dimension changes introduces a risk of state inconsistency. By capturing |
||
|
|
||
| // 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(); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
postDelayedworkaround for dimension changes introduces a potential crash risk. If theVideoPlayeris disposed (and theExoPlayerreleased) during the 150ms delay, the calls toexoPlayer.seekTo()andexoPlayer.play()inside the delayed callback will throw anIllegalStateExceptionbecause the player has been released.The current guard
if (trackSelector == null)(line 414) is likely ineffective becausetrackSelectoris afinalfield in this class and is not nullified during thedispose()call in the current implementation of this plugin.Recommendation:
Use a mechanism to cancel the pending callback or a robust way to check if the player has been disposed. For example, you could use a member
Handlerand callhandler.removeCallbacksAndMessages(null)indispose(), or maintain anisDisposedboolean flag that is set totrueindispose()and checked at the beginning of the delayed callback.