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..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 @@ -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. @@ -601,17 +635,61 @@ private int determineIdealSelectedIndex(long nowMs, long chunkDurationUs) { int lowestBitrateAllowedIndex = 0; for (int i = 0; i < length; i++) { if (nowMs == Long.MIN_VALUE || !isTrackExcluded(i, nowMs)) { - Format format = getFormat(i); + lowestBitrateAllowedIndex = i; + } + } + @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++) { + int formatIndex = + formatPriorityOrder == null + ? getFormatIndexForPriorityPosition(i, nowMs, chunkDurationUs, effectiveBitrate) + : formatPriorityOrder[i]; + if (nowMs == Long.MIN_VALUE || !isTrackExcluded(formatIndex, nowMs)) { + Format format = getFormat(formatIndex); if (canSelectFormat(format, format.bitrate, effectiveBitrate)) { - return i; - } else { - lowestBitrateAllowedIndex = i; + return formatIndex; } } } return lowestBitrateAllowedIndex; } + 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) { + 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..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 @@ -121,6 +121,113 @@ 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 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);