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..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 @@ -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,24 @@ 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; + } + + /** Returns the comparator used to order formats in adaptive track selections. */ + protected final Comparator getTrackFormatComparator() { + return trackFormatComparator; } @Override @@ -289,7 +310,8 @@ protected AdaptiveTrackSelection createAdaptiveTrackSelection( bandwidthFraction, bufferedFractionToLiveEdgeForQualityIncrease, adaptationCheckpoints, - clock); + clock, + getTrackFormatComparator()); } } @@ -389,7 +411,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 +528,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 = determineIdealSelectedIndex(nowMs, effectiveBitrate); return; } @@ -458,22 +545,20 @@ public void updateSelectedTrack( previousSelectedIndex = formatIndexOfPreviousChunk; previousReason = Iterables.getLast(queue).trackSelectionReason; } - int newSelectedIndex = determineIdealSelectedIndex(nowMs, chunkDurationUs); + int newSelectedIndex = determineIdealSelectedIndex(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); 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 && bufferedDurationUs >= maxDurationForQualityDecreaseUs) { - // The selected track is a lower quality, but we have sufficient buffer to defer switching + // The selected track is lower priority, but we have sufficient buffer to defer switching // down for now. newSelectedIndex = previousSelectedIndex; } @@ -521,7 +606,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 @@ -593,11 +679,9 @@ 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) { - long effectiveBitrate = getAllocatedBandwidth(chunkDurationUs); + 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)) { 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..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 @@ -29,28 +29,32 @@ 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; // Lazily initialized hashcode. @@ -75,17 +79,28 @@ 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); + 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++) { 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..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 @@ -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,51 @@ 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_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); + 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(format3); + assertThat(adaptiveTrackSelection.getSelectionReason()).isEqualTo(C.SELECTION_REASON_INITIAL); + } + @Test public void updateSelectedTrackDoNotSwitchDownIfBufferedEnough() { Format format1 = videoFormat(/* bitrate= */ 500, /* width= */ 320, /* height= */ 240); @@ -487,6 +533,69 @@ 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_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); + 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)); + + // 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 public void updateSelectedTrack_withAdaptationCheckpoints_usesOnlyAllocatedBandwidth() { Format format0 = videoFormat(/* bitrate= */ 100, /* width= */ 160, /* height= */ 120); @@ -824,6 +933,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 +1018,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; + } }