[video_player_android] Add video track selection support#11475
[video_player_android] Add video track selection support#11475nateshmbhat wants to merge 7 commits into
Conversation
Implements getVideoTracks() and selectVideoTrack() methods for video track (quality) selection using ExoPlayer. Android breakout PR for flutter#10688.
There was a problem hiding this comment.
Code Review
This pull request introduces video track selection capabilities to the Android video player plugin. Key changes include adding getVideoTracks(), selectVideoTrack(), and enableAutoVideoQuality() methods to the VideoPlayerInstanceApi and their implementations in VideoPlayer.java and android_video_player.dart. A workaround is implemented in selectVideoTrack to handle video dimension changes by temporarily disabling and re-enabling the video track type to force a renderer reset. New Pigeon messages and data structures are introduced to support video track information exchange, and ExoPlayerEventListener is updated to notify about video track changes. Comprehensive unit tests for the new video track functionalities have also been added. The review comment points out a potential crash risk in the postDelayed workaround within VideoPlayer.java if the player is disposed during the 150ms delay, as the current trackSelector == null guard is ineffective. It recommends using a cancellable callback mechanism or an isDisposed flag for robust handling.
| 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); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Addressed in 7bdc032. Switched to a member Handler plus an isDisposed flag; dispose() now calls removeCallbacksAndMessages(null) and sets the flag before releasing the player, so the 150 ms dimension-change callback cannot run on a released ExoPlayer. Added a Robolectric regression test (testSelectVideoTrack_disposeDuringDimensionChangeDelayDoesNotCrash) that uses ShadowLooper.idleFor(200ms) after dispose() and asserts no seekTo/play is invoked.
|
This PR requires #11474 to land and be published. |
5ebe09b to
12597e9
Compare
|
@stuartmorgan-g PR unblocked. good to merge ? |
There was a problem hiding this comment.
Code Review
This pull request implements video track selection for the Android video player using ExoPlayer, including methods to retrieve available tracks, select a specific track, and enable auto-quality selection. The implementation includes a workaround for renderer resets when video dimensions change and updates the Pigeon-generated messaging layer. Feedback is provided regarding potential race conditions in the Dart selectVideoTrack implementation where concurrent calls could overwrite shared state, and concerns are raised about state inconsistency and potential crashes during the 150ms delay used for dimension-change renderer resets.
| Future<void> selectVideoTrack(VideoTrack? track) async { | ||
| // Create a completer to wait for the track selection to complete | ||
| _videoTrackSelectionCompleter = Completer<void>(); | ||
|
|
||
| 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<String> 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; | ||
| } | ||
| } |
There was a problem hiding this comment.
The selectVideoTrack implementation is vulnerable to race conditions when multiple calls occur concurrently. Since _videoTrackSelectionCompleter and _expectedVideoTrackId are shared fields, a second call will overwrite the state of an ongoing first call. Additionally, the finally block of the first call to complete will clear these fields, which can cause subsequent calls to timeout or resolve prematurely with incorrect state. Using local variables to guard the cleanup ensures that each call only clears the fields if it is still the 'current' active request.
Future<void> selectVideoTrack(VideoTrack? track) async {
final Completer<void> completer = Completer<void>();
_videoTrackSelectionCompleter = completer;
final String? expectedId = track?.id;
_expectedVideoTrackId = expectedId;
try {
if (track == null) {
// Auto quality - use dedicated method
await _api.enableAutoVideoQuality();
} else {
// Extract groupIndex and trackIndex from the track id
final List<String> 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]);
await _api.selectVideoTrack(groupIndex, trackIndex);
}
// Wait for the onTracksChanged event from ExoPlayer with a timeout
await completer.future.timeout(
const Duration(seconds: 5),
onTimeout: () {
// If we timeout, just continue - the track may still have been selected
},
);
} finally {
if (_videoTrackSelectionCompleter == completer) {
_videoTrackSelectionCompleter = null;
}
if (_expectedVideoTrackId == expectedId) {
_expectedVideoTrackId = null;
}
}
}There was a problem hiding this comment.
Addressed in 7bdc032. Captured the completer and expected id in locals (completer, expectedId) and guarded the finally cleanup with identical(_videoTrackSelectionCompleter, completer) / _expectedVideoTrackId == expectedId, so an older overlapping selectVideoTrack call can no longer null out a newer call's state. Also merged the null/non-null track branches to remove the duplicated wait/cleanup logic. Added a regression test (concurrent selectVideoTrack calls do not clobber each other) that fails fast on call 1 and verifies call 2 still completes on its matching VideoTrackChangedEvent.
No PRs can me merged without review. This needs final re-review by the folks who looked at the original combination PR. |
…deoTrack race - VideoPlayer.java: replace throwaway Handler + ineffective trackSelector null-check with a member mainHandler and isDisposed flag; dispose() now cancels the 150ms dimension-change callback so it cannot run on a released ExoPlayer. - android_video_player.dart: capture completer/expectedId in locals and guard cleanup with identical()/== so an older concurrent selectVideoTrack call no longer clears a newer call's state. - Add Java regression test (Robolectric ShadowLooper) for dispose during the dimension-change delay, and a Dart concurrent-call regression test.
| final int groupIndex = int.parse(parts[0]); | ||
| final int trackIndex = int.parse(parts[1]); |
There was a problem hiding this comment.
Is it possible these could end up not being ints? int.parse will crash if so.
There was a problem hiding this comment.
No not possible
There was a problem hiding this comment.
Updated this path to parse Android track IDs with int.tryParse and throw ArgumentError if the numeric contract is violated, rather than letting a FormatException bubble out. Added a regression test for non-numeric track IDs as well.
| await completer.future.timeout( | ||
| const Duration(seconds: 5), | ||
| onTimeout: () { | ||
| // If we timeout, just continue - the track may still have been selected |
There was a problem hiding this comment.
might be worth logging something for debugging purposes.
There was a problem hiding this comment.
Added debug logging on the 5-second timeout path so we emit whether the stalled selection was for a specific track or auto quality selection. There is also a test that advances the timeout and asserts that the log is produced.
| // Find the selected track within this group | ||
| for (int i = 0; i < group.length; i++) { | ||
| if (group.isTrackSelected(i)) { | ||
| return groupIndex + "_" + i; |
There was a problem hiding this comment.
since this is mirrored on the dart side, we should probably add a comment on both to ensure that if this changes it's changed in both locations
There was a problem hiding this comment.
Added sync comments on both sides. ExoPlayerEventListener.findSelectedAudioTrackId and findSelectedVideoTrackId now point at android_video_player.dart::_parseAndroidTrackId, and the Dart parser notes that the groupIndex_trackIndex contract is shared with the Java emitters.
| boolean dimensionsChanged = | ||
| currentFormat != null | ||
| && (currentFormat.width != newFormat.width || currentFormat.height != newFormat.height); |
There was a problem hiding this comment.
If currentFormat evaluates to null because the player hasn't fully negotiated or initialized the stream format yet, dimensionsChanged will resolve to false. This bypasses this workaround. if current == null and new != null should we consider that "dimensionsChanged"?
There was a problem hiding this comment.
Updated the dimension-change check so currentFormat == null also takes the renderer-reset workaround path. That keeps the safe behavior when the current stream format has not been negotiated yet, and there is now a Robolectric regression test covering that branch.
Summary
Android breakout PR for #10688.
getVideoTracks()andselectVideoTrack()methods using ExoPlayer'sTrackSelectionOverrideonVideoTrackChangedevent callback for track change notificationsDependency Chain
This PR is second in a series of breakout PRs:
video_player_platform_interface([video_player_platform_interface] Add video track selection support #11474) - pendingvideo_player_android(this PR)video_player_avfoundation(pending)video_player+video_player_web(pending - original PR [video_player] : Add video track selection support for Android and iOS #10688 updated)Note: This PR depends on
video_player_platform_interface6.7.0 being published first.Test Plan
getVideoTracks()andselectVideoTrack()inVideoPlayerTest.javaonVideoTrackChangedcallback inVideoPlayerEventCallbacksTest.javaAndroidVideoPlayeroverrides