From 782857742a53a8fb5d5ec7499a26203d7581efe5 Mon Sep 17 00:00:00 2001 From: Wang Family Date: Wed, 11 Feb 2026 00:03:55 -0800 Subject: [PATCH 1/2] Add AdaptiveTrackSelection format priority ordering hooks --- .../AdaptiveTrackSelection.java | 73 ++++++++++++++++++- .../AdaptiveTrackSelectionTest.java | 36 +++++++++ 2 files changed, 105 insertions(+), 4 deletions(-) diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/trackselection/AdaptiveTrackSelection.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/trackselection/AdaptiveTrackSelection.java index 993c50a24e5..7331077797f 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/trackselection/AdaptiveTrackSelection.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/trackselection/AdaptiveTrackSelection.java @@ -564,6 +564,40 @@ protected boolean canSelectFormat(Format format, int trackBitrate, long effectiv return trackBitrate <= effectiveBitrate; } + /** + * Returns the preferred format evaluation order. + * + * @return An array with one format index per priority position, or null to use {@link + * #getFormatIndexForPriorityPosition(int, long, long, long)}. If non-null, indices must be + * unique and in range {@code [0, length())}. + * + * @param nowMs The current time in the timebase of {@link Clock#elapsedRealtime()}, or {@link + * Long#MIN_VALUE} to ignore track exclusion. + * @param chunkDurationUs The duration of a media chunk in microseconds, or {@link C#TIME_UNSET} + * if unknown. + * @param effectiveBitrate The bitrate available to this selection. + */ + @Nullable + protected int[] getFormatPriorityOrder(long nowMs, long chunkDurationUs, long effectiveBitrate) { + return null; + } + + /** + * Returns the format index to evaluate at a given priority position. The default implementation + * evaluates formats in order of decreasing bitrate. + * + * @param priorityPosition The zero-based position within the format evaluation priority. + * @param nowMs The current time in the timebase of {@link Clock#elapsedRealtime()}, or {@link + * Long#MIN_VALUE} to ignore track exclusion. + * @param chunkDurationUs The duration of a media chunk in microseconds, or {@link C#TIME_UNSET} + * if unknown. + * @param effectiveBitrate The bitrate available to this selection. + */ + protected int getFormatIndexForPriorityPosition( + int priorityPosition, long nowMs, long chunkDurationUs, long effectiveBitrate) { + return priorityPosition; + } + /** * Called from {@link #evaluateQueueSize(long, List)} to determine whether an evaluation should be * performed. @@ -599,19 +633,50 @@ protected long getMinDurationToRetainAfterDiscardUs() { private int determineIdealSelectedIndex(long nowMs, long chunkDurationUs) { long effectiveBitrate = getAllocatedBandwidth(chunkDurationUs); int lowestBitrateAllowedIndex = 0; + @Nullable + int[] formatPriorityOrder = getFormatPriorityOrder(nowMs, chunkDurationUs, effectiveBitrate); + if (formatPriorityOrder != null && !isValidFormatPriorityOrder(formatPriorityOrder)) { + Log.w(TAG, "Ignoring invalid format priority order"); + formatPriorityOrder = null; + } for (int i = 0; i < length; i++) { - if (nowMs == Long.MIN_VALUE || !isTrackExcluded(i, nowMs)) { - Format format = getFormat(i); + int formatIndex = + formatPriorityOrder == null + ? getFormatIndexForPriorityPosition(i, nowMs, chunkDurationUs, effectiveBitrate) + : formatPriorityOrder[i]; + if (formatIndex < 0 || formatIndex >= length) { + Log.w(TAG, "Ignoring out of bounds format index in priority order: " + formatIndex); + formatIndex = i; + } + if (nowMs == Long.MIN_VALUE || !isTrackExcluded(formatIndex, nowMs)) { + Format format = getFormat(formatIndex); if (canSelectFormat(format, format.bitrate, effectiveBitrate)) { - return i; + return formatIndex; } else { - lowestBitrateAllowedIndex = i; + lowestBitrateAllowedIndex = formatIndex; } } } return lowestBitrateAllowedIndex; } + private boolean isValidFormatPriorityOrder(int[] formatPriorityOrder) { + if (formatPriorityOrder.length != length) { + return false; + } + boolean[] seen = new boolean[length]; + for (int formatIndex : formatPriorityOrder) { + if (formatIndex < 0 || formatIndex >= length) { + return false; + } + if (seen[formatIndex]) { + return false; + } + seen[formatIndex] = true; + } + return true; + } + private long minDurationForQualityIncreaseUs(long availableDurationUs, long chunkDurationUs) { if (availableDurationUs == C.TIME_UNSET) { // We are not in a live stream. Use the configured value. diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/trackselection/AdaptiveTrackSelectionTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/trackselection/AdaptiveTrackSelectionTest.java index 4197917c4df..dfce0527ab0 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/trackselection/AdaptiveTrackSelectionTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/trackselection/AdaptiveTrackSelectionTest.java @@ -121,6 +121,42 @@ public void initial_updateSelectedTrack_returnsCorrectLatestBitrateEstimate() { assertThat(adaptiveTrackSelection.getLatestBitrateEstimate()).isEqualTo(2000L); } + @Test + public void initial_updateSelectedTrack_withCustomFormatPriorityOrder_selectsFirstEligible() { + Format format1 = videoFormat(/* bitrate= */ 500, /* width= */ 320, /* height= */ 240); + Format format2 = videoFormat(/* bitrate= */ 1000, /* width= */ 640, /* height= */ 480); + Format format3 = videoFormat(/* bitrate= */ 2000, /* width= */ 960, /* height= */ 720); + TrackGroup trackGroup = new TrackGroup(format1, format2, format3); + + when(mockBandwidthMeter.getBitrateEstimate()).thenReturn(5000L); + AdaptiveTrackSelection adaptiveTrackSelection = + prepareTrackSelection( + new AdaptiveTrackSelection( + trackGroup, + selectedAllTracksInGroup(trackGroup), + TrackSelection.TYPE_UNSET, + mockBandwidthMeter, + AdaptiveTrackSelection.DEFAULT_MIN_DURATION_FOR_QUALITY_INCREASE_MS, + AdaptiveTrackSelection.DEFAULT_MAX_DURATION_FOR_QUALITY_DECREASE_MS, + AdaptiveTrackSelection.DEFAULT_MIN_DURATION_TO_RETAIN_AFTER_DISCARD_MS, + AdaptiveTrackSelection.DEFAULT_MAX_WIDTH_TO_DISCARD, + AdaptiveTrackSelection.DEFAULT_MAX_HEIGHT_TO_DISCARD, + /* bandwidthFraction= */ 1.0f, + AdaptiveTrackSelection.DEFAULT_BUFFERED_FRACTION_TO_LIVE_EDGE_FOR_QUALITY_INCREASE, + /* adaptationCheckpoints= */ ImmutableList.of(), + fakeClock) { + @Override + protected int[] getFormatPriorityOrder( + long nowMs, long chunkDurationUs, long effectiveBitrate) { + // Provide explicit priority order: lowest bitrate first. + return new int[] {2, 1, 0}; + } + }); + + assertThat(adaptiveTrackSelection.getSelectedFormat()).isEqualTo(format1); + assertThat(adaptiveTrackSelection.getSelectionReason()).isEqualTo(C.SELECTION_REASON_INITIAL); + } + @Test public void updateSelectedTrackDoNotSwitchUpIfNotBufferedEnough() { Format format1 = videoFormat(/* bitrate= */ 500, /* width= */ 320, /* height= */ 240); From c49a531d65eef533c693bb9e46ffcf56bdcb94bf Mon Sep 17 00:00:00 2001 From: Wang Family Date: Wed, 11 Feb 2026 11:22:00 -0800 Subject: [PATCH 2/2] Refine format-priority fallback and validation in AdaptiveTrackSelection - Keep fallback index tied to bitrate order instead of priority traversal order - Use bitmask validation for <=64 tracks to reduce allocation - Add tests for null and invalid custom priority order fallback --- .../AdaptiveTrackSelection.java | 25 +++++-- .../AdaptiveTrackSelectionTest.java | 71 +++++++++++++++++++ 2 files changed, 90 insertions(+), 6 deletions(-) diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/trackselection/AdaptiveTrackSelection.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/trackselection/AdaptiveTrackSelection.java index 7331077797f..5699980dd68 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/trackselection/AdaptiveTrackSelection.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/trackselection/AdaptiveTrackSelection.java @@ -633,6 +633,11 @@ protected long getMinDurationToRetainAfterDiscardUs() { private int determineIdealSelectedIndex(long nowMs, long chunkDurationUs) { long effectiveBitrate = getAllocatedBandwidth(chunkDurationUs); int lowestBitrateAllowedIndex = 0; + for (int i = 0; i < length; i++) { + if (nowMs == Long.MIN_VALUE || !isTrackExcluded(i, nowMs)) { + lowestBitrateAllowedIndex = i; + } + } @Nullable int[] formatPriorityOrder = getFormatPriorityOrder(nowMs, chunkDurationUs, effectiveBitrate); if (formatPriorityOrder != null && !isValidFormatPriorityOrder(formatPriorityOrder)) { @@ -644,16 +649,10 @@ private int determineIdealSelectedIndex(long nowMs, long chunkDurationUs) { formatPriorityOrder == null ? getFormatIndexForPriorityPosition(i, nowMs, chunkDurationUs, effectiveBitrate) : formatPriorityOrder[i]; - if (formatIndex < 0 || formatIndex >= length) { - Log.w(TAG, "Ignoring out of bounds format index in priority order: " + formatIndex); - formatIndex = i; - } if (nowMs == Long.MIN_VALUE || !isTrackExcluded(formatIndex, nowMs)) { Format format = getFormat(formatIndex); if (canSelectFormat(format, format.bitrate, effectiveBitrate)) { return formatIndex; - } else { - lowestBitrateAllowedIndex = formatIndex; } } } @@ -664,6 +663,20 @@ private boolean isValidFormatPriorityOrder(int[] formatPriorityOrder) { if (formatPriorityOrder.length != length) { return false; } + if (length <= Long.SIZE) { + long seenMask = 0L; + for (int formatIndex : formatPriorityOrder) { + if (formatIndex < 0 || formatIndex >= length) { + return false; + } + long bit = 1L << formatIndex; + if ((seenMask & bit) != 0L) { + return false; + } + seenMask |= bit; + } + return true; + } boolean[] seen = new boolean[length]; for (int formatIndex : formatPriorityOrder) { if (formatIndex < 0 || formatIndex >= length) { diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/trackselection/AdaptiveTrackSelectionTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/trackselection/AdaptiveTrackSelectionTest.java index dfce0527ab0..96f08dad8cf 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/trackselection/AdaptiveTrackSelectionTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/trackselection/AdaptiveTrackSelectionTest.java @@ -157,6 +157,77 @@ protected int[] getFormatPriorityOrder( assertThat(adaptiveTrackSelection.getSelectionReason()).isEqualTo(C.SELECTION_REASON_INITIAL); } + @Test + public void initial_updateSelectedTrack_withNoFormatPriorityOrder_usesDefaultBitrateOrder() { + Format format1 = videoFormat(/* bitrate= */ 500, /* width= */ 320, /* height= */ 240); + Format format2 = videoFormat(/* bitrate= */ 1000, /* width= */ 640, /* height= */ 480); + Format format3 = videoFormat(/* bitrate= */ 2000, /* width= */ 960, /* height= */ 720); + TrackGroup trackGroup = new TrackGroup(format1, format2, format3); + + when(mockBandwidthMeter.getBitrateEstimate()).thenReturn(5000L); + AdaptiveTrackSelection adaptiveTrackSelection = + prepareTrackSelection( + new AdaptiveTrackSelection( + trackGroup, + selectedAllTracksInGroup(trackGroup), + TrackSelection.TYPE_UNSET, + mockBandwidthMeter, + AdaptiveTrackSelection.DEFAULT_MIN_DURATION_FOR_QUALITY_INCREASE_MS, + AdaptiveTrackSelection.DEFAULT_MAX_DURATION_FOR_QUALITY_DECREASE_MS, + AdaptiveTrackSelection.DEFAULT_MIN_DURATION_TO_RETAIN_AFTER_DISCARD_MS, + AdaptiveTrackSelection.DEFAULT_MAX_WIDTH_TO_DISCARD, + AdaptiveTrackSelection.DEFAULT_MAX_HEIGHT_TO_DISCARD, + /* bandwidthFraction= */ 1.0f, + AdaptiveTrackSelection.DEFAULT_BUFFERED_FRACTION_TO_LIVE_EDGE_FOR_QUALITY_INCREASE, + /* adaptationCheckpoints= */ ImmutableList.of(), + fakeClock) { + @Override + protected int[] getFormatPriorityOrder( + long nowMs, long chunkDurationUs, long effectiveBitrate) { + return null; + } + }); + + assertThat(adaptiveTrackSelection.getSelectedFormat()).isEqualTo(format3); + assertThat(adaptiveTrackSelection.getSelectionReason()).isEqualTo(C.SELECTION_REASON_INITIAL); + } + + @Test + public void initial_updateSelectedTrack_withInvalidFormatPriorityOrder_usesDefaultBitrateOrder() { + Format format1 = videoFormat(/* bitrate= */ 500, /* width= */ 320, /* height= */ 240); + Format format2 = videoFormat(/* bitrate= */ 1000, /* width= */ 640, /* height= */ 480); + Format format3 = videoFormat(/* bitrate= */ 2000, /* width= */ 960, /* height= */ 720); + TrackGroup trackGroup = new TrackGroup(format1, format2, format3); + + when(mockBandwidthMeter.getBitrateEstimate()).thenReturn(5000L); + AdaptiveTrackSelection adaptiveTrackSelection = + prepareTrackSelection( + new AdaptiveTrackSelection( + trackGroup, + selectedAllTracksInGroup(trackGroup), + TrackSelection.TYPE_UNSET, + mockBandwidthMeter, + AdaptiveTrackSelection.DEFAULT_MIN_DURATION_FOR_QUALITY_INCREASE_MS, + AdaptiveTrackSelection.DEFAULT_MAX_DURATION_FOR_QUALITY_DECREASE_MS, + AdaptiveTrackSelection.DEFAULT_MIN_DURATION_TO_RETAIN_AFTER_DISCARD_MS, + AdaptiveTrackSelection.DEFAULT_MAX_WIDTH_TO_DISCARD, + AdaptiveTrackSelection.DEFAULT_MAX_HEIGHT_TO_DISCARD, + /* bandwidthFraction= */ 1.0f, + AdaptiveTrackSelection.DEFAULT_BUFFERED_FRACTION_TO_LIVE_EDGE_FOR_QUALITY_INCREASE, + /* adaptationCheckpoints= */ ImmutableList.of(), + fakeClock) { + @Override + protected int[] getFormatPriorityOrder( + long nowMs, long chunkDurationUs, long effectiveBitrate) { + // Invalid because of duplicate format index. + return new int[] {2, 2, 0}; + } + }); + + assertThat(adaptiveTrackSelection.getSelectedFormat()).isEqualTo(format3); + assertThat(adaptiveTrackSelection.getSelectionReason()).isEqualTo(C.SELECTION_REASON_INITIAL); + } + @Test public void updateSelectedTrackDoNotSwitchUpIfNotBufferedEnough() { Format format1 = videoFormat(/* bitrate= */ 500, /* width= */ 320, /* height= */ 240);