Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/video_player/video_player_android/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Comment on lines +410 to +431
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The postDelayed workaround for dimension changes introduces a potential crash risk. If the VideoPlayer is disposed (and the ExoPlayer released) during the 150ms delay, the calls to exoPlayer.seekTo() and exoPlayer.play() inside the delayed callback will throw an IllegalStateException because the player has been released.

The current guard if (trackSelector == null) (line 414) is likely ineffective because trackSelector is a final field in this class and is not nullified during the dispose() 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 Handler and call handler.removeCallbacksAndMessages(null) in dispose(), or maintain an isDisposed boolean flag that is set to true in dispose() and checked at the beginning of the delayed callback.

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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,6 @@ public interface VideoPlayerCallbacks {
void onIsPlayingStateUpdate(boolean isPlaying);

void onAudioTrackChanged(@Nullable String selectedTrackId);

void onVideoTrackChanged(@Nullable String selectedTrackId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
Loading