From d29702f9e5811aa000bda3e6b43a4fe8aecba1f6 Mon Sep 17 00:00:00 2001 From: AndyWangLYN Date: Thu, 12 Feb 2026 13:58:16 -0800 Subject: [PATCH 1/3] Add adaptive track format comparator with safe fallback --- .../AdaptiveTrackSelection.java | 131 +++++++++-- .../trackselection/BaseTrackSelection.java | 43 +++- .../trackselection/TrackSelection.java | 3 +- .../AdaptiveTrackSelectionTest.java | 204 ++++++++++++++++++ 4 files changed, 357 insertions(+), 24 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..97c7b5c7638 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 @@ -15,6 +15,7 @@ */ package androidx.media3.exoplayer.trackselection; +import static com.google.common.base.Preconditions.checkNotNull; import static java.lang.Math.max; import static java.lang.Math.min; @@ -39,11 +40,12 @@ import com.google.common.collect.MultimapBuilder; import java.util.ArrayList; import java.util.Arrays; +import java.util.Comparator; import java.util.List; /** * A bandwidth based adaptive {@link ExoTrackSelection}, whose selected track is updated to be the - * one of highest quality given the current network conditions and the state of the buffer. + * highest priority track given the current network conditions and the state of the buffer. */ @UnstableApi public class AdaptiveTrackSelection extends BaseTrackSelection { @@ -61,6 +63,7 @@ public static class Factory implements ExoTrackSelection.Factory { private final float bandwidthFraction; private final float bufferedFractionToLiveEdgeForQualityIncrease; private final Clock clock; + private Comparator trackFormatComparator; /** Creates an adaptive track selection factory with default parameters. */ public Factory() { @@ -227,6 +230,19 @@ public Factory( this.bufferedFractionToLiveEdgeForQualityIncrease = bufferedFractionToLiveEdgeForQualityIncrease; this.clock = clock; + this.trackFormatComparator = BaseTrackSelection.DEFAULT_FORMAT_COMPARATOR; + } + + /** + * Sets the comparator used to order formats in adaptive track selections. + * The comparator order controls which formats are considered first during adaptation. + * + * @param trackFormatComparator Comparator used to order selected formats. + * @return This factory, for convenience. + */ + public Factory setTrackFormatComparator(Comparator trackFormatComparator) { + this.trackFormatComparator = checkNotNull(trackFormatComparator); + return this; } @Override @@ -289,7 +305,8 @@ protected AdaptiveTrackSelection createAdaptiveTrackSelection( bandwidthFraction, bufferedFractionToLiveEdgeForQualityIncrease, adaptationCheckpoints, - clock); + clock, + trackFormatComparator); } } @@ -389,7 +406,71 @@ protected AdaptiveTrackSelection( float bufferedFractionToLiveEdgeForQualityIncrease, List adaptationCheckpoints, Clock clock) { - super(group, tracks, type); + this( + group, + tracks, + type, + bandwidthMeter, + minDurationForQualityIncreaseMs, + maxDurationForQualityDecreaseMs, + minDurationToRetainAfterDiscardMs, + maxWidthToDiscard, + maxHeightToDiscard, + bandwidthFraction, + bufferedFractionToLiveEdgeForQualityIncrease, + adaptationCheckpoints, + clock, + BaseTrackSelection.DEFAULT_FORMAT_COMPARATOR); + } + + /** + * @param group The {@link TrackGroup}. + * @param tracks The indices of the selected tracks within the {@link TrackGroup}. Must not be + * empty. May be in any order. + * @param type The type that will be returned from {@link TrackSelection#getType()}. + * @param bandwidthMeter Provides an estimate of the currently available bandwidth. + * @param minDurationForQualityIncreaseMs The minimum duration of buffered data required for the + * selected track to switch to one of higher quality. + * @param maxDurationForQualityDecreaseMs The maximum duration of buffered data required for the + * selected track to switch to one of lower quality. + * @param minDurationToRetainAfterDiscardMs When switching to a video track of higher quality, the + * selection may indicate that media already buffered at the lower quality can be discarded to + * speed up the switch. This is the minimum duration of media that must be retained at the + * lower quality. It must be at least {@code minDurationForQualityIncreaseMs}. + * @param maxWidthToDiscard The maximum video width that the selector may discard from the buffer + * to speed up switching to a higher quality. + * @param maxHeightToDiscard The maximum video height that the selector may discard from the + * buffer to speed up switching to a higher quality. + * @param bandwidthFraction The fraction of the available bandwidth that the selection should + * consider available for use. Setting to a value less than 1 is recommended to account for + * inaccuracies in the bandwidth estimator. + * @param bufferedFractionToLiveEdgeForQualityIncrease For live streaming, the fraction of the + * duration from current playback position to the live edge that has to be buffered before the + * selected track can be switched to one of higher quality. This parameter is only applied + * when the playback position is closer to the live edge than {@code + * minDurationForQualityIncreaseMs}, which would otherwise prevent switching to a higher + * quality from happening. + * @param adaptationCheckpoints The {@link AdaptationCheckpoint checkpoints} that can be used to + * calculate available bandwidth for this selection. + * @param clock The {@link Clock}. + * @param trackFormatComparator Comparator used to order selected formats. + */ + protected AdaptiveTrackSelection( + TrackGroup group, + int[] tracks, + @Type int type, + BandwidthMeter bandwidthMeter, + long minDurationForQualityIncreaseMs, + long maxDurationForQualityDecreaseMs, + long minDurationToRetainAfterDiscardMs, + int maxWidthToDiscard, + int maxHeightToDiscard, + float bandwidthFraction, + float bufferedFractionToLiveEdgeForQualityIncrease, + List adaptationCheckpoints, + Clock clock, + Comparator trackFormatComparator) { + super(group, tracks, type, trackFormatComparator); if (minDurationToRetainAfterDiscardMs < minDurationForQualityIncreaseMs) { Log.w( TAG, @@ -442,11 +523,12 @@ public void updateSelectedTrack( MediaChunkIterator[] mediaChunkIterators) { long nowMs = clock.elapsedRealtime(); long chunkDurationUs = getNextChunkDurationUs(mediaChunkIterators, queue); + long effectiveBitrate = getAllocatedBandwidth(chunkDurationUs); // Make initial selection if (reason == C.SELECTION_REASON_UNKNOWN) { reason = C.SELECTION_REASON_INITIAL; - selectedIndex = determineIdealSelectedIndex(nowMs, chunkDurationUs); + selectedIndex = determineIdealSelectedIndexForEffectiveBitrate(nowMs, effectiveBitrate); return; } @@ -458,23 +540,24 @@ public void updateSelectedTrack( previousSelectedIndex = formatIndexOfPreviousChunk; previousReason = Iterables.getLast(queue).trackSelectionReason; } - int newSelectedIndex = determineIdealSelectedIndex(nowMs, chunkDurationUs); + int newSelectedIndex = determineIdealSelectedIndexForEffectiveBitrate(nowMs, effectiveBitrate); if (newSelectedIndex != previousSelectedIndex && !isTrackExcluded(previousSelectedIndex, nowMs)) { - // Revert back to the previous selection if conditions are not suitable for switching. - Format currentFormat = getFormat(previousSelectedIndex); - Format selectedFormat = getFormat(newSelectedIndex); + // Revert back to the previous selection if conditions are not suitable for switching. Do not + // defer a switch when the previous format no longer fits into available bandwidth. long minDurationForQualityIncreaseUs = minDurationForQualityIncreaseUs(availableDurationUs, chunkDurationUs); - if (selectedFormat.bitrate > currentFormat.bitrate + if (newSelectedIndex < previousSelectedIndex && bufferedDurationUs < minDurationForQualityIncreaseUs) { - // The selected track is a higher quality, but we have insufficient buffer to safely switch + // The selected track is higher priority, but we have insufficient buffer to safely switch // up. Defer switching up for now. newSelectedIndex = previousSelectedIndex; - } else if (selectedFormat.bitrate < currentFormat.bitrate + } else if (newSelectedIndex > previousSelectedIndex + && (isUsingDefaultFormatComparator() + || isTrackSelectable(previousSelectedIndex, effectiveBitrate)) && bufferedDurationUs >= maxDurationForQualityDecreaseUs) { - // The selected track is a lower quality, but we have sufficient buffer to defer switching - // down for now. + // The selected track is lower priority, but we have sufficient buffer to defer switching + // down while preserving existing behavior for default ordering. newSelectedIndex = previousSelectedIndex; } } @@ -597,19 +680,33 @@ protected long getMinDurationToRetainAfterDiscardUs() { * if unknown. */ private int determineIdealSelectedIndex(long nowMs, long chunkDurationUs) { - long effectiveBitrate = getAllocatedBandwidth(chunkDurationUs); - int lowestBitrateAllowedIndex = 0; + return determineIdealSelectedIndexForEffectiveBitrate(nowMs, getAllocatedBandwidth(chunkDurationUs)); + } + + private int determineIdealSelectedIndexForEffectiveBitrate(long nowMs, long effectiveBitrate) { + int lowestBitrateAllowedIndex = C.INDEX_UNSET; + int lowestBitrate = Integer.MAX_VALUE; for (int i = 0; i < length; i++) { if (nowMs == Long.MIN_VALUE || !isTrackExcluded(i, nowMs)) { Format format = getFormat(i); if (canSelectFormat(format, format.bitrate, effectiveBitrate)) { return i; - } else { + } + int formatBitrate = format.bitrate == Format.NO_VALUE ? 0 : format.bitrate; + if (formatBitrate <= lowestBitrate) { + // fallback semantics by selecting the lowest bitrate non-excluded track when no track + // is selectable within available bandwidth. lowestBitrateAllowedIndex = i; + lowestBitrate = formatBitrate; } } } - return lowestBitrateAllowedIndex; + return lowestBitrateAllowedIndex == C.INDEX_UNSET ? 0 : lowestBitrateAllowedIndex; + } + + private boolean isTrackSelectable(int index, long effectiveBitrate) { + Format format = getFormat(index); + return canSelectFormat(format, format.bitrate, effectiveBitrate); } private long minDurationForQualityIncreaseUs(long availableDurationUs, long chunkDurationUs) { diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/trackselection/BaseTrackSelection.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/trackselection/BaseTrackSelection.java index 287495754d6..5e4d6c0b7e4 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/trackselection/BaseTrackSelection.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/trackselection/BaseTrackSelection.java @@ -29,30 +29,37 @@ import androidx.media3.common.util.Util; import androidx.media3.exoplayer.source.chunk.MediaChunk; import java.util.Arrays; +import java.util.Comparator; import java.util.List; /** An abstract base class suitable for most {@link ExoTrackSelection} implementations. */ @UnstableApi public abstract class BaseTrackSelection implements ExoTrackSelection { + static final Comparator DEFAULT_FORMAT_COMPARATOR = + (firstFormat, secondFormat) -> Integer.compare(secondFormat.bitrate, firstFormat.bitrate); + /** The selected {@link TrackGroup}. */ protected final TrackGroup group; /** The number of selected tracks within the {@link TrackGroup}. Always greater than zero. */ protected final int length; - /** The indices of the selected tracks in {@link #group}, in order of decreasing bandwidth. */ + /** The indices of the selected tracks in {@link #group}, in selection order. */ protected final int[] tracks; /** The type of the selection. */ private final @Type int type; - /** The {@link Format}s of the selected tracks, in order of decreasing bandwidth. */ + /** The {@link Format}s of the selected tracks, in selection order. */ private final Format[] formats; - /** Selected track exclusion timestamps, in order of decreasing bandwidth. */ + /** Selected track exclusion timestamps, in selection order. */ private final long[] excludeUntilTimes; + /** Whether selected formats are ordered with the default bitrate comparator. */ + private final boolean isUsingDefaultFormatComparator; + // Lazily initialized hashcode. private int hashCode; @@ -75,23 +82,42 @@ public BaseTrackSelection(TrackGroup group, int... tracks) { * @param type The type that will be returned from {@link TrackSelection#getType()}. */ public BaseTrackSelection(TrackGroup group, int[] tracks, @Type int type) { + this(group, tracks, type, DEFAULT_FORMAT_COMPARATOR); + } + + /** + * @param group The {@link TrackGroup}. Must not be null. + * @param tracks The indices of the selected tracks within the {@link TrackGroup}. Must not be + * null or empty. May be in any order. + * @param type The type that will be returned from {@link TrackSelection#getType()}. + * @param formatComparator Comparator that determines the order of selected {@link Format}s. + */ + protected BaseTrackSelection( + TrackGroup group, int[] tracks, @Type int type, Comparator formatComparator) { checkState(tracks.length > 0); this.type = type; this.group = checkNotNull(group); this.length = tracks.length; - // Set the formats, sorted in order of decreasing bandwidth. + // Set the formats in selection order. formats = new Format[length]; for (int i = 0; i < tracks.length; i++) { formats[i] = group.getFormat(tracks[i]); } - // Sort in order of decreasing bandwidth. - Arrays.sort(formats, (a, b) -> b.bitrate - a.bitrate); + Comparator safeFormatComparator = checkNotNull(formatComparator); + boolean isUsingDefaultFormatComparator = safeFormatComparator == DEFAULT_FORMAT_COMPARATOR; + try { + Arrays.sort(formats, safeFormatComparator); + } catch (Throwable throwable) { + Arrays.sort(formats, DEFAULT_FORMAT_COMPARATOR); + isUsingDefaultFormatComparator = true; + } // Set the format indices in the same order. this.tracks = new int[length]; for (int i = 0; i < length; i++) { this.tracks[i] = group.indexOf(formats[i]); } excludeUntilTimes = new long[length]; + this.isUsingDefaultFormatComparator = isUsingDefaultFormatComparator; playWhenReady = false; } @@ -208,6 +234,11 @@ protected final boolean getPlayWhenReady() { return playWhenReady; } + /** Returns whether formats are ordered with the default bitrate comparator. */ + protected final boolean isUsingDefaultFormatComparator() { + return isUsingDefaultFormatComparator; + } + // Object overrides. @Override diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/trackselection/TrackSelection.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/trackselection/TrackSelection.java index 47d1e87735f..ba837fc3ff5 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/trackselection/TrackSelection.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/trackselection/TrackSelection.java @@ -31,7 +31,8 @@ * A track selection consisting of a static subset of selected tracks belonging to a {@link * TrackGroup}. * - *

Tracks belonging to the subset are exposed in decreasing bandwidth order. + *

Tracks belonging to the subset are exposed in selection order, which by default is decreasing + * bandwidth order. */ @UnstableApi public interface TrackSelection { 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..946859b6c2f 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 @@ -38,6 +38,7 @@ import com.google.common.collect.ImmutableList; import java.util.ArrayList; import java.util.Collections; +import java.util.Comparator; import java.util.List; import org.junit.Before; import org.junit.Test; @@ -215,6 +216,104 @@ public void updateSelectedTrack_liveStream_switchesUpWhenBufferedFractionToLiveE assertThat(adaptiveTrackSelection.getSelectedFormat()).isEqualTo(format3); } + @Test + public void initial_updateSelectedTrack_usesFactoryProvidedTrackFormatComparatorOrder() { + 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); + AdaptiveTrackSelection.Factory factory = + new AdaptiveTrackSelection.Factory( + /* minDurationForQualityIncreaseMs= */ 10_000, + /* maxDurationForQualityDecreaseMs= */ 25_000, + /* minDurationToRetainAfterDiscardMs= */ 25_000, + /* bandwidthFraction= */ 1f) + .setTrackFormatComparator( + formatComparatorInOrder(ImmutableList.of(format2, format3, format1))); + + when(mockBandwidthMeter.getBitrateEstimate()).thenReturn(2000L); + AdaptiveTrackSelection adaptiveTrackSelection = + factory.createAdaptiveTrackSelection( + trackGroup, + /* tracks= */ new int[] {0, 1, 2}, + /* type= */ TrackSelection.TYPE_UNSET, + mockBandwidthMeter, + /* adaptationCheckpoints= */ ImmutableList.of()); + adaptiveTrackSelection = prepareTrackSelection(adaptiveTrackSelection); + + assertThat(adaptiveTrackSelection.getSelectedFormat()).isEqualTo(format2); + assertThat(adaptiveTrackSelection.getSelectionReason()).isEqualTo(C.SELECTION_REASON_INITIAL); + } + + @Test + public void initial_updateSelectedTrack_withNoSelectableFormat_fallsBackToLowestBitrate() { + 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(499L); + AdaptiveTrackSelection adaptiveTrackSelection = + prepareAdaptiveTrackSelectionWithFormatComparator( + trackGroup, formatComparatorInOrder(ImmutableList.of(format2, format1, format3))); + + assertThat(adaptiveTrackSelection.getSelectedFormat()).isEqualTo(format1); + assertThat(adaptiveTrackSelection.getSelectionReason()).isEqualTo(C.SELECTION_REASON_INITIAL); + } + + @Test + public void initial_updateSelectedTrack_withInvalidComparator_fallsBackToDefaultOrdering() { + 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); + Comparator throwingComparator = + (firstFormat, secondFormat) -> { + throw new RuntimeException("Comparator failure"); + }; + + when(mockBandwidthMeter.getBitrateEstimate()).thenReturn(1000L); + AdaptiveTrackSelection adaptiveTrackSelection = + prepareAdaptiveTrackSelectionWithFormatComparator(trackGroup, throwingComparator); + + assertThat(adaptiveTrackSelection.getSelectedFormat()).isEqualTo(format2); + assertThat(adaptiveTrackSelection.getSelectionReason()).isEqualTo(C.SELECTION_REASON_INITIAL); + } + + @Test + public void + updateSelectedTrack_withInvalidComparator_preservesDefaultDownSwitchDeferralWhenBufferedEnough() { + 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); + Comparator throwingComparator = + (firstFormat, secondFormat) -> { + throw new RuntimeException("Comparator failure"); + }; + + // The second measurement onward returns 500L, which prompts the track selection to switch down + // if necessary. + when(mockBandwidthMeter.getBitrateEstimate()).thenReturn(1000L, 500L); + AdaptiveTrackSelection adaptiveTrackSelection = + prepareAdaptiveTrackSelectionWithFormatComparatorAndDurations( + trackGroup, + AdaptiveTrackSelection.DEFAULT_MIN_DURATION_FOR_QUALITY_INCREASE_MS, + /* maxDurationForQualityDecreaseMs= */ 25_000, + throwingComparator); + + adaptiveTrackSelection.updateSelectedTrack( + /* playbackPositionUs= */ 0, + /* bufferedDurationUs= */ 25_000_000, + /* availableDurationUs= */ C.TIME_UNSET, + /* queue= */ Collections.emptyList(), + createMediaChunkIterators(trackGroup, TEST_CHUNK_DURATION_US)); + + // Invalid comparators fall back to the default ordering and preserve the default behavior. + assertThat(adaptiveTrackSelection.getSelectedFormat()).isEqualTo(format2); + assertThat(adaptiveTrackSelection.getSelectionReason()).isEqualTo(C.SELECTION_REASON_INITIAL); + } + @Test public void updateSelectedTrackDoNotSwitchDownIfBufferedEnough() { Format format1 = videoFormat(/* bitrate= */ 500, /* width= */ 320, /* height= */ 240); @@ -487,6 +586,67 @@ public void updateSelectedTrack_withQueueOfUnknownFormats_doesntThrow() { assertThat(adaptiveTrackSelection.getSelectedFormat()).isAnyOf(format1, format2); } + @Test + public void updateSelectedTrack_withCustomFormatOrder_usesCustomOrderForSwitchDirection() { + 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); + Comparator formatComparator = + formatComparatorInOrder(ImmutableList.of(format1, format2, format3)); + when(mockBandwidthMeter.getBitrateEstimate()).thenReturn(2000L); + AdaptiveTrackSelection adaptiveTrackSelection = + prepareAdaptiveTrackSelectionWithFormatComparatorAndDurations( + trackGroup, + /* minDurationForQualityIncreaseMs= */ 10_000, + /* maxDurationForQualityDecreaseMs= */ 25_000, + formatComparator); + + FakeMediaChunk chunk = + new FakeMediaChunk( + format2, /* startTimeUs= */ 0, /* endTimeUs= */ 2_000_000, C.SELECTION_REASON_INITIAL); + adaptiveTrackSelection.updateSelectedTrack( + /* playbackPositionUs= */ 0, + /* bufferedDurationUs= */ 4_000_000, + /* availableDurationUs= */ C.TIME_UNSET, + /* queue= */ ImmutableList.of(chunk), + createMediaChunkIterators(trackGroup, TEST_CHUNK_DURATION_US)); + + // format1 is higher priority than format2 according to the custom order, so switching from + // format2 to format1 is treated as a switch-up and deferred when not buffered enough. + assertThat(adaptiveTrackSelection.getSelectedFormat()).isEqualTo(format2); + assertThat(adaptiveTrackSelection.getSelectionReason()).isEqualTo(C.SELECTION_REASON_INITIAL); + } + + @Test + public void + updateSelectedTrack_withCustomFormatOrder_switchesDownIfCurrentTrackExceedsBandwidth() { + 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); + Comparator formatComparator = + formatComparatorInOrder(ImmutableList.of(format2, format1, format3)); + + when(mockBandwidthMeter.getBitrateEstimate()).thenReturn(1000L, 500L); + AdaptiveTrackSelection adaptiveTrackSelection = + prepareAdaptiveTrackSelectionWithFormatComparatorAndDurations( + trackGroup, + AdaptiveTrackSelection.DEFAULT_MIN_DURATION_FOR_QUALITY_INCREASE_MS, + /* maxDurationForQualityDecreaseMs= */ 25_000, + formatComparator); + + adaptiveTrackSelection.updateSelectedTrack( + /* playbackPositionUs= */ 0, + /* bufferedDurationUs= */ 25_000_000, + /* availableDurationUs= */ C.TIME_UNSET, + /* queue= */ Collections.emptyList(), + createMediaChunkIterators(trackGroup, TEST_CHUNK_DURATION_US)); + + assertThat(adaptiveTrackSelection.getSelectedFormat()).isEqualTo(format1); + assertThat(adaptiveTrackSelection.getSelectionReason()).isEqualTo(C.SELECTION_REASON_ADAPTIVE); + } + @Test public void updateSelectedTrack_withAdaptationCheckpoints_usesOnlyAllocatedBandwidth() { Format format0 = videoFormat(/* bitrate= */ 100, /* width= */ 160, /* height= */ 120); @@ -824,6 +984,38 @@ private AdaptiveTrackSelection prepareAdaptiveTrackSelectionWithAdaptationCheckp fakeClock)); } + private AdaptiveTrackSelection prepareAdaptiveTrackSelectionWithFormatComparator( + TrackGroup trackGroup, Comparator trackFormatComparator) { + return prepareAdaptiveTrackSelectionWithFormatComparatorAndDurations( + trackGroup, + AdaptiveTrackSelection.DEFAULT_MIN_DURATION_FOR_QUALITY_INCREASE_MS, + AdaptiveTrackSelection.DEFAULT_MAX_DURATION_FOR_QUALITY_DECREASE_MS, + trackFormatComparator); + } + + private AdaptiveTrackSelection prepareAdaptiveTrackSelectionWithFormatComparatorAndDurations( + TrackGroup trackGroup, + long minDurationForQualityIncreaseMs, + long maxDurationForQualityDecreaseMs, + Comparator trackFormatComparator) { + return prepareTrackSelection( + new AdaptiveTrackSelection( + trackGroup, + selectedAllTracksInGroup(trackGroup), + TrackSelection.TYPE_UNSET, + mockBandwidthMeter, + minDurationForQualityIncreaseMs, + maxDurationForQualityDecreaseMs, + 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, + trackFormatComparator)); + } + private AdaptiveTrackSelection prepareTrackSelection( AdaptiveTrackSelection adaptiveTrackSelection) { adaptiveTrackSelection.enable(); @@ -877,4 +1069,16 @@ private static Format videoFormat(int bitrate, int width, int height) { .setHeight(height) .build(); } + + private static Comparator formatComparatorInOrder(List orderedFormats) { + return (firstFormat, secondFormat) -> + Integer.compare( + getFormatOrderIndex(orderedFormats, firstFormat), + getFormatOrderIndex(orderedFormats, secondFormat)); + } + + private static int getFormatOrderIndex(List orderedFormats, Format format) { + int index = orderedFormats.indexOf(format); + return index == C.INDEX_UNSET ? Integer.MAX_VALUE : index; + } } From 1fa4d560cb7cd80266386a4b72e067181fdbb88f Mon Sep 17 00:00:00 2001 From: AndyWangLYN Date: Thu, 19 Feb 2026 23:07:38 -0800 Subject: [PATCH 2/3] Apply comparator-driven ABR selection without default-specific branching --- .../AdaptiveTrackSelection.java | 40 ++++-------- .../trackselection/BaseTrackSelection.java | 18 +---- .../AdaptiveTrackSelectionTest.java | 65 ++----------------- 3 files changed, 19 insertions(+), 104 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 97c7b5c7638..eb548f1cf6e 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 @@ -528,7 +528,7 @@ public void updateSelectedTrack( // Make initial selection if (reason == C.SELECTION_REASON_UNKNOWN) { reason = C.SELECTION_REASON_INITIAL; - selectedIndex = determineIdealSelectedIndexForEffectiveBitrate(nowMs, effectiveBitrate); + selectedIndex = determineIdealSelectedIndex(nowMs, effectiveBitrate); return; } @@ -540,11 +540,10 @@ public void updateSelectedTrack( previousSelectedIndex = formatIndexOfPreviousChunk; previousReason = Iterables.getLast(queue).trackSelectionReason; } - int newSelectedIndex = determineIdealSelectedIndexForEffectiveBitrate(nowMs, effectiveBitrate); + int newSelectedIndex = determineIdealSelectedIndex(nowMs, effectiveBitrate); if (newSelectedIndex != previousSelectedIndex && !isTrackExcluded(previousSelectedIndex, nowMs)) { - // Revert back to the previous selection if conditions are not suitable for switching. Do not - // defer a switch when the previous format no longer fits into available bandwidth. + // Revert back to the previous selection if conditions are not suitable for switching. long minDurationForQualityIncreaseUs = minDurationForQualityIncreaseUs(availableDurationUs, chunkDurationUs); if (newSelectedIndex < previousSelectedIndex @@ -553,11 +552,9 @@ public void updateSelectedTrack( // up. Defer switching up for now. newSelectedIndex = previousSelectedIndex; } else if (newSelectedIndex > previousSelectedIndex - && (isUsingDefaultFormatComparator() - || isTrackSelectable(previousSelectedIndex, effectiveBitrate)) && bufferedDurationUs >= maxDurationForQualityDecreaseUs) { // The selected track is lower priority, but we have sufficient buffer to defer switching - // down while preserving existing behavior for default ordering. + // down for now. newSelectedIndex = previousSelectedIndex; } } @@ -604,7 +601,8 @@ public int evaluateQueueSize(long playbackPositionUs, List if (playoutBufferedDurationBeforeLastChunkUs < minDurationToRetainAfterDiscardUs) { return queueSize; } - int idealSelectedIndex = determineIdealSelectedIndex(nowMs, getLastChunkDurationUs(queue)); + int idealSelectedIndex = + determineIdealSelectedIndex(nowMs, getAllocatedBandwidth(getLastChunkDurationUs(queue))); Format idealFormat = getFormat(idealSelectedIndex); // If chunks contain video, discard from the first chunk after minDurationToRetainAfterDiscardUs // whose resolution and bitrate are both lower than the ideal track, and whose width and height @@ -676,37 +674,21 @@ protected long getMinDurationToRetainAfterDiscardUs() { * * @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. */ - private int determineIdealSelectedIndex(long nowMs, long chunkDurationUs) { - return determineIdealSelectedIndexForEffectiveBitrate(nowMs, getAllocatedBandwidth(chunkDurationUs)); - } - - private int determineIdealSelectedIndexForEffectiveBitrate(long nowMs, long effectiveBitrate) { - int lowestBitrateAllowedIndex = C.INDEX_UNSET; - int lowestBitrate = Integer.MAX_VALUE; + private int determineIdealSelectedIndex(long nowMs, long effectiveBitrate) { + int lowestBitrateAllowedIndex = 0; for (int i = 0; i < length; i++) { if (nowMs == Long.MIN_VALUE || !isTrackExcluded(i, nowMs)) { Format format = getFormat(i); if (canSelectFormat(format, format.bitrate, effectiveBitrate)) { return i; - } - int formatBitrate = format.bitrate == Format.NO_VALUE ? 0 : format.bitrate; - if (formatBitrate <= lowestBitrate) { - // fallback semantics by selecting the lowest bitrate non-excluded track when no track - // is selectable within available bandwidth. + } else { lowestBitrateAllowedIndex = i; - lowestBitrate = formatBitrate; } } } - return lowestBitrateAllowedIndex == C.INDEX_UNSET ? 0 : lowestBitrateAllowedIndex; - } - - private boolean isTrackSelectable(int index, long effectiveBitrate) { - Format format = getFormat(index); - return canSelectFormat(format, format.bitrate, effectiveBitrate); + return lowestBitrateAllowedIndex; } private long minDurationForQualityIncreaseUs(long availableDurationUs, long chunkDurationUs) { diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/trackselection/BaseTrackSelection.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/trackselection/BaseTrackSelection.java index 5e4d6c0b7e4..3da7b14576b 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/trackselection/BaseTrackSelection.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/trackselection/BaseTrackSelection.java @@ -57,9 +57,6 @@ public abstract class BaseTrackSelection implements ExoTrackSelection { /** Selected track exclusion timestamps, in selection order. */ private final long[] excludeUntilTimes; - /** Whether selected formats are ordered with the default bitrate comparator. */ - private final boolean isUsingDefaultFormatComparator; - // Lazily initialized hashcode. private int hashCode; @@ -103,21 +100,13 @@ protected BaseTrackSelection( for (int i = 0; i < tracks.length; i++) { formats[i] = group.getFormat(tracks[i]); } - Comparator safeFormatComparator = checkNotNull(formatComparator); - boolean isUsingDefaultFormatComparator = safeFormatComparator == DEFAULT_FORMAT_COMPARATOR; - try { - Arrays.sort(formats, safeFormatComparator); - } catch (Throwable throwable) { - Arrays.sort(formats, DEFAULT_FORMAT_COMPARATOR); - isUsingDefaultFormatComparator = true; - } + Arrays.sort(formats, checkNotNull(formatComparator)); // Set the format indices in the same order. this.tracks = new int[length]; for (int i = 0; i < length; i++) { this.tracks[i] = group.indexOf(formats[i]); } excludeUntilTimes = new long[length]; - this.isUsingDefaultFormatComparator = isUsingDefaultFormatComparator; playWhenReady = false; } @@ -234,11 +223,6 @@ protected final boolean getPlayWhenReady() { return playWhenReady; } - /** Returns whether formats are ordered with the default bitrate comparator. */ - protected final boolean isUsingDefaultFormatComparator() { - return isUsingDefaultFormatComparator; - } - // Object overrides. @Override 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 946859b6c2f..b21e8a510e4 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 @@ -246,7 +246,7 @@ public void initial_updateSelectedTrack_usesFactoryProvidedTrackFormatComparator } @Test - public void initial_updateSelectedTrack_withNoSelectableFormat_fallsBackToLowestBitrate() { + public void initial_updateSelectedTrack_withNoSelectableFormat_fallsBackToLowestPriorityFormat() { 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); @@ -257,60 +257,7 @@ public void initial_updateSelectedTrack_withNoSelectableFormat_fallsBackToLowest prepareAdaptiveTrackSelectionWithFormatComparator( trackGroup, formatComparatorInOrder(ImmutableList.of(format2, format1, format3))); - assertThat(adaptiveTrackSelection.getSelectedFormat()).isEqualTo(format1); - assertThat(adaptiveTrackSelection.getSelectionReason()).isEqualTo(C.SELECTION_REASON_INITIAL); - } - - @Test - public void initial_updateSelectedTrack_withInvalidComparator_fallsBackToDefaultOrdering() { - 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); - Comparator throwingComparator = - (firstFormat, secondFormat) -> { - throw new RuntimeException("Comparator failure"); - }; - - when(mockBandwidthMeter.getBitrateEstimate()).thenReturn(1000L); - AdaptiveTrackSelection adaptiveTrackSelection = - prepareAdaptiveTrackSelectionWithFormatComparator(trackGroup, throwingComparator); - - assertThat(adaptiveTrackSelection.getSelectedFormat()).isEqualTo(format2); - assertThat(adaptiveTrackSelection.getSelectionReason()).isEqualTo(C.SELECTION_REASON_INITIAL); - } - - @Test - public void - updateSelectedTrack_withInvalidComparator_preservesDefaultDownSwitchDeferralWhenBufferedEnough() { - 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); - Comparator throwingComparator = - (firstFormat, secondFormat) -> { - throw new RuntimeException("Comparator failure"); - }; - - // The second measurement onward returns 500L, which prompts the track selection to switch down - // if necessary. - when(mockBandwidthMeter.getBitrateEstimate()).thenReturn(1000L, 500L); - AdaptiveTrackSelection adaptiveTrackSelection = - prepareAdaptiveTrackSelectionWithFormatComparatorAndDurations( - trackGroup, - AdaptiveTrackSelection.DEFAULT_MIN_DURATION_FOR_QUALITY_INCREASE_MS, - /* maxDurationForQualityDecreaseMs= */ 25_000, - throwingComparator); - - adaptiveTrackSelection.updateSelectedTrack( - /* playbackPositionUs= */ 0, - /* bufferedDurationUs= */ 25_000_000, - /* availableDurationUs= */ C.TIME_UNSET, - /* queue= */ Collections.emptyList(), - createMediaChunkIterators(trackGroup, TEST_CHUNK_DURATION_US)); - - // Invalid comparators fall back to the default ordering and preserve the default behavior. - assertThat(adaptiveTrackSelection.getSelectedFormat()).isEqualTo(format2); + assertThat(adaptiveTrackSelection.getSelectedFormat()).isEqualTo(format3); assertThat(adaptiveTrackSelection.getSelectionReason()).isEqualTo(C.SELECTION_REASON_INITIAL); } @@ -620,7 +567,7 @@ public void updateSelectedTrack_withCustomFormatOrder_usesCustomOrderForSwitchDi @Test public void - updateSelectedTrack_withCustomFormatOrder_switchesDownIfCurrentTrackExceedsBandwidth() { + updateSelectedTrack_withCustomFormatOrder_defersSwitchDownWhenBufferedEnough() { 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); @@ -643,8 +590,10 @@ public void updateSelectedTrack_withCustomFormatOrder_usesCustomOrderForSwitchDi /* queue= */ Collections.emptyList(), createMediaChunkIterators(trackGroup, TEST_CHUNK_DURATION_US)); - assertThat(adaptiveTrackSelection.getSelectedFormat()).isEqualTo(format1); - assertThat(adaptiveTrackSelection.getSelectionReason()).isEqualTo(C.SELECTION_REASON_ADAPTIVE); + // With sufficient buffer (bufferedDurationUs >= maxDurationForQualityDecreaseUs), the + // down-switch is deferred regardless of which comparator is in use. + assertThat(adaptiveTrackSelection.getSelectedFormat()).isEqualTo(format2); + assertThat(adaptiveTrackSelection.getSelectionReason()).isEqualTo(C.SELECTION_REASON_INITIAL); } @Test From 0f2dca2b690291af78deac81b04ca48c9a522acc Mon Sep 17 00:00:00 2001 From: AndyWangLYN Date: Thu, 19 Feb 2026 23:45:57 -0800 Subject: [PATCH 3/3] Add a getter for trackFormatComparator in AdaptiveTrackSelection --- .../exoplayer/trackselection/AdaptiveTrackSelection.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 eb548f1cf6e..bf2986ea96f 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 @@ -245,6 +245,11 @@ public Factory setTrackFormatComparator(Comparator trackFormatComparator return this; } + /** Returns the comparator used to order formats in adaptive track selections. */ + protected final Comparator getTrackFormatComparator() { + return trackFormatComparator; + } + @Override public final @NullableType ExoTrackSelection[] createTrackSelections( @NullableType Definition[] definitions, @@ -306,7 +311,7 @@ protected AdaptiveTrackSelection createAdaptiveTrackSelection( bufferedFractionToLiveEdgeForQualityIncrease, adaptationCheckpoints, clock, - trackFormatComparator); + getTrackFormatComparator()); } }