diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 9d98f2aae0..80c55c4706 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -2,6 +2,8 @@ ### dev-v2 (not yet released) ### +* Add ability for `SequenceableLoader` to reevaluate its buffer and discard + buffered media so that it can be re-buffered in a different quality. * Replace `DefaultTrackSelector.Parameters` copy methods with a builder. * Allow more flexible loading strategy when playing media containing multiple sub-streams, by allowing injection of custom `CompositeSequenceableLoader` diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java index b0ef675e71..83e7858eaa 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImplInternal.java @@ -1283,6 +1283,7 @@ import java.io.IOException; // Update the loading period if required. maybeUpdateLoadingPeriod(); + if (loadingPeriodHolder == null || loadingPeriodHolder.isFullyBuffered()) { setIsLoading(false); } else if (loadingPeriodHolder != null && !isLoading) { @@ -1386,6 +1387,7 @@ import java.io.IOException; if (loadingPeriodHolder == null) { info = mediaPeriodInfoSequence.getFirstMediaPeriodInfo(playbackInfo); } else { + loadingPeriodHolder.reevaluateBuffer(rendererPositionUs); if (loadingPeriodHolder.info.isFinal || !loadingPeriodHolder.isFullyBuffered() || loadingPeriodHolder.info.durationUs == C.TIME_UNSET) { return; @@ -1440,6 +1442,7 @@ import java.io.IOException; // Stale event. return; } + loadingPeriodHolder.reevaluateBuffer(rendererPositionUs); maybeContinueLoading(); } @@ -1628,13 +1631,18 @@ import java.io.IOException; info = info.copyWithStartPositionUs(newStartPositionUs); } + public void reevaluateBuffer(long rendererPositionUs) { + if (prepared) { + mediaPeriod.reevaluateBuffer(toPeriodTime(rendererPositionUs)); + } + } + public boolean shouldContinueLoading(long rendererPositionUs, float playbackSpeed) { long nextLoadPositionUs = !prepared ? 0 : mediaPeriod.getNextLoadPositionUs(); if (nextLoadPositionUs == C.TIME_END_OF_SOURCE) { return false; } else { - long loadingPeriodPositionUs = toPeriodTime(rendererPositionUs); - long bufferedDurationUs = nextLoadPositionUs - loadingPeriodPositionUs; + long bufferedDurationUs = nextLoadPositionUs - toPeriodTime(rendererPositionUs); return loadControl.shouldContinueLoading(bufferedDurationUs, playbackSpeed); } } @@ -1694,7 +1702,6 @@ import java.io.IOException; Assertions.checkState(trackSelections.get(i) == null); } } - // The track selection has changed. loadControl.onTracksSelected(renderers, trackSelectorResult.groups, trackSelections); return positionUs; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ClippingMediaPeriod.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ClippingMediaPeriod.java index 36e8e51ffb..539c4841e9 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ClippingMediaPeriod.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ClippingMediaPeriod.java @@ -123,6 +123,11 @@ public final class ClippingMediaPeriod implements MediaPeriod, MediaPeriod.Callb mediaPeriod.discardBuffer(positionUs + startUs, toKeyframe); } + @Override + public void reevaluateBuffer(long positionUs) { + mediaPeriod.reevaluateBuffer(positionUs + startUs); + } + @Override public long readDiscontinuity() { if (isPendingInitialDiscontinuity()) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/CompositeSequenceableLoader.java b/library/core/src/main/java/com/google/android/exoplayer2/source/CompositeSequenceableLoader.java index e9a187a747..c41933b48b 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/CompositeSequenceableLoader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/CompositeSequenceableLoader.java @@ -52,6 +52,13 @@ public class CompositeSequenceableLoader implements SequenceableLoader { return nextLoadPositionUs == Long.MAX_VALUE ? C.TIME_END_OF_SOURCE : nextLoadPositionUs; } + @Override + public final void reevaluateBuffer(long positionUs) { + for (SequenceableLoader loader : loaders) { + loader.reevaluateBuffer(positionUs); + } + } + @Override public boolean continueLoading(long positionUs) { boolean madeProgress = false; diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/DeferredMediaPeriod.java b/library/core/src/main/java/com/google/android/exoplayer2/source/DeferredMediaPeriod.java index bc29b2fdf1..32a180b956 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/DeferredMediaPeriod.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/DeferredMediaPeriod.java @@ -119,6 +119,11 @@ public final class DeferredMediaPeriod implements MediaPeriod, MediaPeriod.Callb return mediaPeriod.getNextLoadPositionUs(); } + @Override + public void reevaluateBuffer(long positionUs) { + mediaPeriod.reevaluateBuffer(positionUs); + } + @Override public boolean continueLoading(long positionUs) { return mediaPeriod != null && mediaPeriod.continueLoading(positionUs); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/ExtractorMediaPeriod.java b/library/core/src/main/java/com/google/android/exoplayer2/source/ExtractorMediaPeriod.java index 17a6c3bcb8..6b9aeb39da 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/ExtractorMediaPeriod.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/ExtractorMediaPeriod.java @@ -288,6 +288,11 @@ import java.util.Arrays; } } + @Override + public void reevaluateBuffer(long positionUs) { + // Do nothing. + } + @Override public boolean continueLoading(long playbackPositionUs) { if (loadingFinished || (prepared && enabledTrackCount == 0)) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/MediaPeriod.java b/library/core/src/main/java/com/google/android/exoplayer2/source/MediaPeriod.java index 439562e0ab..54b34bc531 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/MediaPeriod.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/MediaPeriod.java @@ -35,27 +35,25 @@ public interface MediaPeriod extends SequenceableLoader { /** * Called when preparation completes. - *

- * Called on the playback thread. After invoking this method, the {@link MediaPeriod} can expect - * for {@link #selectTracks(TrackSelection[], boolean[], SampleStream[], boolean[], long)} to be - * called with the initial track selection. + * + *

Called on the playback thread. After invoking this method, the {@link MediaPeriod} can + * expect for {@link #selectTracks(TrackSelection[], boolean[], SampleStream[], boolean[], + * long)} to be called with the initial track selection. * * @param mediaPeriod The prepared {@link MediaPeriod}. */ void onPrepared(MediaPeriod mediaPeriod); - } /** * Prepares this media period asynchronously. - *

- * {@code callback.onPrepared} is called when preparation completes. If preparation fails, + * + *

{@code callback.onPrepared} is called when preparation completes. If preparation fails, * {@link #maybeThrowPrepareError()} will throw an {@link IOException}. - *

- * If preparation succeeds and results in a source timeline change (e.g. the period duration - * becoming known), - * {@link MediaSource.Listener#onSourceInfoRefreshed(MediaSource, Timeline, Object)} will be - * called before {@code callback.onPrepared}. + * + *

If preparation succeeds and results in a source timeline change (e.g. the period duration + * becoming known), {@link MediaSource.Listener#onSourceInfoRefreshed(MediaSource, Timeline, + * Object)} will be called before {@code callback.onPrepared}. * * @param callback Callback to receive updates from this period, including being notified when * preparation completes. @@ -66,8 +64,8 @@ public interface MediaPeriod extends SequenceableLoader { /** * Throws an error that's preventing the period from becoming prepared. Does nothing if no such * error exists. - *

- * This method should only be called before the period has completed preparation. + * + *

This method should only be called before the period has completed preparation. * * @throws IOException The underlying error. */ @@ -75,8 +73,8 @@ public interface MediaPeriod extends SequenceableLoader { /** * Returns the {@link TrackGroup}s exposed by the period. - *

- * This method should only be called after the period has been prepared. + * + *

This method should only be called after the period has been prepared. * * @return The {@link TrackGroup}s. */ @@ -84,16 +82,16 @@ public interface MediaPeriod extends SequenceableLoader { /** * Performs a track selection. - *

- * The call receives track {@code selections} for each renderer, {@code mayRetainStreamFlags} + * + *

The call receives track {@code selections} for each renderer, {@code mayRetainStreamFlags} * indicating whether the existing {@code SampleStream} can be retained for each selection, and * the existing {@code stream}s themselves. The call will update {@code streams} to reflect the * provided selections, clearing, setting and replacing entries as required. If an existing sample * stream is retained but with the requirement that the consuming renderer be reset, then the * corresponding flag in {@code streamResetFlags} will be set to true. This flag will also be set * if a new sample stream is created. - *

- * This method should only be called after the period has been prepared. + * + *

This method should only be called after the period has been prepared. * * @param selections The renderer track selections. * @param mayRetainStreamFlags Flags indicating whether the existing sample stream can be retained @@ -104,16 +102,20 @@ public interface MediaPeriod extends SequenceableLoader { * @param streamResetFlags Will be updated to indicate new sample streams, and sample streams that * have been retained but with the requirement that the consuming renderer be reset. * @param positionUs The current playback position in microseconds. If playback of this period has - * not yet started, the value will be the starting position. + * not yet started, the value will be the starting position. * @return The actual position at which the tracks were enabled, in microseconds. */ - long selectTracks(TrackSelection[] selections, boolean[] mayRetainStreamFlags, - SampleStream[] streams, boolean[] streamResetFlags, long positionUs); + long selectTracks( + TrackSelection[] selections, + boolean[] mayRetainStreamFlags, + SampleStream[] streams, + boolean[] streamResetFlags, + long positionUs); /** * Discards buffered media up to the specified position. - *

- * This method should only be called after the period has been prepared. + * + *

This method should only be called after the period has been prepared. * * @param positionUs The position in microseconds. * @param toKeyframe If true then for each track discards samples up to the keyframe before or at @@ -123,11 +125,11 @@ public interface MediaPeriod extends SequenceableLoader { /** * Attempts to read a discontinuity. - *

- * After this method has returned a value other than {@link C#TIME_UNSET}, all - * {@link SampleStream}s provided by the period are guaranteed to start from a key frame. - *

- * This method should only be called after the period has been prepared. + * + *

After this method has returned a value other than {@link C#TIME_UNSET}, all {@link + * SampleStream}s provided by the period are guaranteed to start from a key frame. + * + *

This method should only be called after the period has been prepared. * * @return If a discontinuity was read then the playback position in microseconds after the * discontinuity. Else {@link C#TIME_UNSET}. @@ -136,11 +138,11 @@ public interface MediaPeriod extends SequenceableLoader { /** * Attempts to seek to the specified position in microseconds. - *

- * After this method has been called, all {@link SampleStream}s provided by the period are + * + *

After this method has been called, all {@link SampleStream}s provided by the period are * guaranteed to start from a key frame. - *

- * This method should only be called when at least one track is selected. + * + *

This method should only be called when at least one track is selected. * * @param positionUs The seek position in microseconds. * @return The actual position to which the period was seeked, in microseconds. @@ -151,8 +153,8 @@ public interface MediaPeriod extends SequenceableLoader { /** * Returns an estimate of the position up to which data is buffered for the enabled tracks. - *

- * This method should only be called when at least one track is selected. + * + *

This method should only be called when at least one track is selected. * * @return An estimate of the absolute position in microseconds up to which data is buffered, or * {@link C#TIME_END_OF_SOURCE} if the track is fully buffered. @@ -162,19 +164,19 @@ public interface MediaPeriod extends SequenceableLoader { /** * Returns the next load time, or {@link C#TIME_END_OF_SOURCE} if loading has finished. - *

- * This method should only be called after the period has been prepared. It may be called when no - * tracks are selected. + * + *

This method should only be called after the period has been prepared. It may be called when + * no tracks are selected. */ @Override long getNextLoadPositionUs(); /** * Attempts to continue loading. - *

- * This method may be called both during and after the period has been prepared. - *

- * A period may call {@link Callback#onContinueLoadingRequested(SequenceableLoader)} on the + * + *

This method may be called both during and after the period has been prepared. + * + *

A period may call {@link Callback#onContinueLoadingRequested(SequenceableLoader)} on the * {@link Callback} passed to {@link #prepare(Callback, long)} to request that this method be * called when the period is permitted to continue loading data. A period may do this both during * and after preparation. @@ -182,10 +184,24 @@ public interface MediaPeriod extends SequenceableLoader { * @param positionUs The current playback position in microseconds. If playback of this period has * not yet started, the value will be the starting position in this period minus the duration * of any media in previous periods still to be played. - * @return True if progress was made, meaning that {@link #getNextLoadPositionUs()} will return - * a different value than prior to the call. False otherwise. + * @return True if progress was made, meaning that {@link #getNextLoadPositionUs()} will return a + * different value than prior to the call. False otherwise. */ @Override boolean continueLoading(long positionUs); + /** + * Re-evaluates the buffer given the playback position. + * + *

This method should only be called after the period has been prepared. + * + *

A period may choose to discard buffered media so that it can be re-buffered in a different + * quality. + * + * @param positionUs The current playback position in microseconds. If playback of this period has + * not yet started, the value will be the starting position in this period minus the duration + * of any media in previous periods still to be played. + */ + @Override + void reevaluateBuffer(long positionUs); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/MergingMediaPeriod.java b/library/core/src/main/java/com/google/android/exoplayer2/source/MergingMediaPeriod.java index bd37b5efec..5ac9fc8d97 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/MergingMediaPeriod.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/MergingMediaPeriod.java @@ -139,6 +139,11 @@ import java.util.IdentityHashMap; } } + @Override + public void reevaluateBuffer(long positionUs) { + compositeSequenceableLoader.reevaluateBuffer(positionUs); + } + @Override public boolean continueLoading(long positionUs) { return compositeSequenceableLoader.continueLoading(positionUs); diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/SequenceableLoader.java b/library/core/src/main/java/com/google/android/exoplayer2/source/SequenceableLoader.java index 6daa1e847a..182f0f17cc 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/SequenceableLoader.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/SequenceableLoader.java @@ -60,4 +60,15 @@ public interface SequenceableLoader { */ boolean continueLoading(long positionUs); + /** + * Re-evaluates the buffer given the playback position. + * + *

Re-evaluation may discard buffered media so that it can be re-buffered in a different + * quality. + * + * @param positionUs The current playback position in microseconds. If playback of this period has + * not yet started, the value will be the starting position in this period minus the duration + * of any media in previous periods still to be played. + */ + void reevaluateBuffer(long positionUs); } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaPeriod.java b/library/core/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaPeriod.java index 0cea0fad66..7b8b54eedc 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaPeriod.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/SingleSampleMediaPeriod.java @@ -120,6 +120,11 @@ import java.util.Arrays; // Do nothing. } + @Override + public void reevaluateBuffer(long positionUs) { + // Do nothing. + } + @Override public boolean continueLoading(long positionUs) { if (loadingFinished || loader.isLoading()) { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSampleStream.java b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSampleStream.java index 20b56e7807..85c4b12241 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSampleStream.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/source/chunk/ChunkSampleStream.java @@ -319,7 +319,9 @@ public class ChunkSampleStream implements SampleStream, S IOException error) { long bytesLoaded = loadable.bytesLoaded(); boolean isMediaChunk = isMediaChunk(loadable); - boolean cancelable = bytesLoaded == 0 || !isMediaChunk || !haveReadFromLastMediaChunk(); + int lastChunkIndex = mediaChunks.size() - 1; + boolean cancelable = + bytesLoaded == 0 || !isMediaChunk || !haveReadFromMediaChunk(lastChunkIndex); boolean canceled = false; if (chunkSource.onChunkLoadError(loadable, cancelable, error)) { if (!cancelable) { @@ -327,12 +329,8 @@ public class ChunkSampleStream implements SampleStream, S } else { canceled = true; if (isMediaChunk) { - BaseMediaChunk removed = mediaChunks.remove(mediaChunks.size() - 1); + BaseMediaChunk removed = discardUpstreamMediaChunksFromIndex(lastChunkIndex); Assertions.checkState(removed == loadable); - primarySampleQueue.discardUpstreamSamples(removed.getFirstSampleIndex(0)); - for (int i = 0; i < embeddedSampleQueues.length; i++) { - embeddedSampleQueues[i].discardUpstreamSamples(removed.getFirstSampleIndex(i + 1)); - } if (mediaChunks.isEmpty()) { pendingResetPositionUs = lastSeekPositionUs; } @@ -405,35 +403,29 @@ public class ChunkSampleStream implements SampleStream, S } } - // Internal methods - - // TODO[REFACTOR]: Call maybeDiscardUpstream for DASH and SmoothStreaming. - /** - * Discards media chunks from the back of the buffer if conditions have changed such that it's - * preferable to re-buffer the media at a different quality. - * - * @param positionUs The current playback position in microseconds. - */ - @SuppressWarnings("unused") - private void maybeDiscardUpstream(long positionUs) { + @Override + public void reevaluateBuffer(long positionUs) { + if (loader.isLoading() || isPendingReset()) { + return; + } int queueSize = chunkSource.getPreferredQueueSize(positionUs, readOnlyMediaChunks); - discardUpstreamMediaChunks(Math.max(1, queueSize)); + discardUpstreamMediaChunks(queueSize); } + // Internal methods + private boolean isMediaChunk(Chunk chunk) { return chunk instanceof BaseMediaChunk; } - /** - * Returns whether samples have been read from {@code mediaChunks.getLast()}. - */ - private boolean haveReadFromLastMediaChunk() { - BaseMediaChunk lastChunk = getLastMediaChunk(); - if (primarySampleQueue.getReadIndex() > lastChunk.getFirstSampleIndex(0)) { + /** Returns whether samples have been read from media chunk at given index. */ + private boolean haveReadFromMediaChunk(int mediaChunkIndex) { + BaseMediaChunk mediaChunk = mediaChunks.get(mediaChunkIndex); + if (primarySampleQueue.getReadIndex() > mediaChunk.getFirstSampleIndex(0)) { return true; } for (int i = 0; i < embeddedSampleQueues.length; i++) { - if (embeddedSampleQueues[i].getReadIndex() > lastChunk.getFirstSampleIndex(i + 1)) { + if (embeddedSampleQueues[i].getReadIndex() > mediaChunk.getFirstSampleIndex(i + 1)) { return true; } } @@ -492,27 +484,51 @@ public class ChunkSampleStream implements SampleStream, S } /** - * Discard upstream media chunks until the queue length is equal to the length specified. + * Discard upstream media chunks until the queue length is equal to the length specified, but + * avoid discarding any chunk whose samples have been read by either primary sample stream or + * embedded sample streams. * - * @param queueLength The desired length of the queue. - * @return Whether chunks were discarded. + * @param desiredQueueSize The desired length of the queue. The final queue size after discarding + * maybe larger than this if there are chunks after the specified position that have been read + * by either primary sample stream or embedded sample streams. */ - private boolean discardUpstreamMediaChunks(int queueLength) { - if (mediaChunks.size() <= queueLength) { - return false; + private void discardUpstreamMediaChunks(int desiredQueueSize) { + if (mediaChunks.size() <= desiredQueueSize) { + return; } + int firstIndexToRemove = desiredQueueSize; + for (int i = firstIndexToRemove; i < mediaChunks.size(); i++) { + if (!haveReadFromMediaChunk(i)) { + firstIndexToRemove = i; + break; + } + } + + if (firstIndexToRemove == mediaChunks.size()) { + return; + } long endTimeUs = getLastMediaChunk().endTimeUs; - BaseMediaChunk firstRemovedChunk = mediaChunks.get(queueLength); - long startTimeUs = firstRemovedChunk.startTimeUs; - Util.removeRange(mediaChunks, /* fromIndex= */ queueLength, /* toIndex= */ mediaChunks.size()); + BaseMediaChunk firstRemovedChunk = discardUpstreamMediaChunksFromIndex(firstIndexToRemove); + loadingFinished = false; + eventDispatcher.upstreamDiscarded(primaryTrackType, firstRemovedChunk.startTimeUs, endTimeUs); + } + + /** + * Discard upstream media chunks from {@code chunkIndex} and corresponding samples from sample + * queues. + * + * @param chunkIndex The index of the first chunk to discard. + * @return The chunk at given index. + */ + private BaseMediaChunk discardUpstreamMediaChunksFromIndex(int chunkIndex) { + BaseMediaChunk firstRemovedChunk = mediaChunks.get(chunkIndex); + Util.removeRange(mediaChunks, /* fromIndex= */ chunkIndex, /* toIndex= */ mediaChunks.size()); primarySampleQueue.discardUpstreamSamples(firstRemovedChunk.getFirstSampleIndex(0)); for (int i = 0; i < embeddedSampleQueues.length; i++) { embeddedSampleQueues[i].discardUpstreamSamples(firstRemovedChunk.getFirstSampleIndex(i + 1)); } - loadingFinished = false; - eventDispatcher.upstreamDiscarded(primaryTrackType, startTimeUs, endTimeUs); - return true; + return firstRemovedChunk; } /** diff --git a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/AdaptiveTrackSelection.java b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/AdaptiveTrackSelection.java index ba45b2b186..973155c2e3 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/trackselection/AdaptiveTrackSelection.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/trackselection/AdaptiveTrackSelection.java @@ -15,12 +15,12 @@ */ package com.google.android.exoplayer2.trackselection; -import android.os.SystemClock; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.source.TrackGroup; import com.google.android.exoplayer2.source.chunk.MediaChunk; import com.google.android.exoplayer2.upstream.BandwidthMeter; +import com.google.android.exoplayer2.util.Clock; import com.google.android.exoplayer2.util.Util; import java.util.List; @@ -42,17 +42,23 @@ public class AdaptiveTrackSelection extends BaseTrackSelection { private final int minDurationToRetainAfterDiscardMs; private final float bandwidthFraction; private final float bufferedFractionToLiveEdgeForQualityIncrease; + private final long minTimeBetweenBufferReevaluationMs; + private final Clock clock; /** * @param bandwidthMeter Provides an estimate of the currently available bandwidth. */ public Factory(BandwidthMeter bandwidthMeter) { - this (bandwidthMeter, DEFAULT_MAX_INITIAL_BITRATE, + this( + bandwidthMeter, + DEFAULT_MAX_INITIAL_BITRATE, DEFAULT_MIN_DURATION_FOR_QUALITY_INCREASE_MS, DEFAULT_MAX_DURATION_FOR_QUALITY_DECREASE_MS, DEFAULT_MIN_DURATION_TO_RETAIN_AFTER_DISCARD_MS, DEFAULT_BANDWIDTH_FRACTION, - DEFAULT_BUFFERED_FRACTION_TO_LIVE_EDGE_FOR_QUALITY_INCREASE); + DEFAULT_BUFFERED_FRACTION_TO_LIVE_EDGE_FOR_QUALITY_INCREASE, + DEFAULT_MIN_TIME_BETWEEN_BUFFER_REEVALUTATION_MS, + Clock.DEFAULT); } /** @@ -74,37 +80,55 @@ public class AdaptiveTrackSelection extends BaseTrackSelection { public Factory(BandwidthMeter bandwidthMeter, int maxInitialBitrate, int minDurationForQualityIncreaseMs, int maxDurationForQualityDecreaseMs, int minDurationToRetainAfterDiscardMs, float bandwidthFraction) { - this (bandwidthMeter, maxInitialBitrate, minDurationForQualityIncreaseMs, - maxDurationForQualityDecreaseMs, minDurationToRetainAfterDiscardMs, - bandwidthFraction, DEFAULT_BUFFERED_FRACTION_TO_LIVE_EDGE_FOR_QUALITY_INCREASE); + this( + bandwidthMeter, + maxInitialBitrate, + minDurationForQualityIncreaseMs, + maxDurationForQualityDecreaseMs, + minDurationToRetainAfterDiscardMs, + bandwidthFraction, + DEFAULT_BUFFERED_FRACTION_TO_LIVE_EDGE_FOR_QUALITY_INCREASE, + DEFAULT_MIN_TIME_BETWEEN_BUFFER_REEVALUTATION_MS, + Clock.DEFAULT); } /** * @param bandwidthMeter Provides an estimate of the currently available bandwidth. - * @param maxInitialBitrate The maximum bitrate in bits per second that should be assumed - * when a bandwidth estimate is unavailable. - * @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 maxInitialBitrate The maximum bitrate in bits per second that should be assumed when a + * bandwidth estimate is unavailable. + * @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 track of significantly 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. * @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. + * 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 minTimeBetweenBufferReevaluationMs The track selection may periodically reevaluate its + * buffer and discard some chunks of lower quality to improve the playback quality if + * network conditions have changed. This is the minimum duration between 2 consecutive + * buffer reevaluation calls. + * @param clock A {@link Clock}. */ - public Factory(BandwidthMeter bandwidthMeter, int maxInitialBitrate, - int minDurationForQualityIncreaseMs, int maxDurationForQualityDecreaseMs, - int minDurationToRetainAfterDiscardMs, float bandwidthFraction, - float bufferedFractionToLiveEdgeForQualityIncrease) { + public Factory( + BandwidthMeter bandwidthMeter, + int maxInitialBitrate, + int minDurationForQualityIncreaseMs, + int maxDurationForQualityDecreaseMs, + int minDurationToRetainAfterDiscardMs, + float bandwidthFraction, + float bufferedFractionToLiveEdgeForQualityIncrease, + long minTimeBetweenBufferReevaluationMs, + Clock clock) { this.bandwidthMeter = bandwidthMeter; this.maxInitialBitrate = maxInitialBitrate; this.minDurationForQualityIncreaseMs = minDurationForQualityIncreaseMs; @@ -113,14 +137,24 @@ public class AdaptiveTrackSelection extends BaseTrackSelection { this.bandwidthFraction = bandwidthFraction; this.bufferedFractionToLiveEdgeForQualityIncrease = bufferedFractionToLiveEdgeForQualityIncrease; + this.minTimeBetweenBufferReevaluationMs = minTimeBetweenBufferReevaluationMs; + this.clock = clock; } @Override public AdaptiveTrackSelection createTrackSelection(TrackGroup group, int... tracks) { - return new AdaptiveTrackSelection(group, tracks, bandwidthMeter, maxInitialBitrate, - minDurationForQualityIncreaseMs, maxDurationForQualityDecreaseMs, - minDurationToRetainAfterDiscardMs, bandwidthFraction, - bufferedFractionToLiveEdgeForQualityIncrease); + return new AdaptiveTrackSelection( + group, + tracks, + bandwidthMeter, + maxInitialBitrate, + minDurationForQualityIncreaseMs, + maxDurationForQualityDecreaseMs, + minDurationToRetainAfterDiscardMs, + bandwidthFraction, + bufferedFractionToLiveEdgeForQualityIncrease, + minTimeBetweenBufferReevaluationMs, + clock); } } @@ -131,6 +165,7 @@ public class AdaptiveTrackSelection extends BaseTrackSelection { public static final int DEFAULT_MIN_DURATION_TO_RETAIN_AFTER_DISCARD_MS = 25000; public static final float DEFAULT_BANDWIDTH_FRACTION = 0.75f; public static final float DEFAULT_BUFFERED_FRACTION_TO_LIVE_EDGE_FOR_QUALITY_INCREASE = 0.75f; + public static final long DEFAULT_MIN_TIME_BETWEEN_BUFFER_REEVALUTATION_MS = 2000; private final BandwidthMeter bandwidthMeter; private final int maxInitialBitrate; @@ -139,10 +174,13 @@ public class AdaptiveTrackSelection extends BaseTrackSelection { private final long minDurationToRetainAfterDiscardUs; private final float bandwidthFraction; private final float bufferedFractionToLiveEdgeForQualityIncrease; + private final long minTimeBetweenBufferReevaluationMs; + private final Clock clock; private float playbackSpeed; private int selectedIndex; private int reason; + private long lastBufferEvaluationMs; /** * @param group The {@link TrackGroup}. @@ -152,12 +190,18 @@ public class AdaptiveTrackSelection extends BaseTrackSelection { */ public AdaptiveTrackSelection(TrackGroup group, int[] tracks, BandwidthMeter bandwidthMeter) { - this (group, tracks, bandwidthMeter, DEFAULT_MAX_INITIAL_BITRATE, + this( + group, + tracks, + bandwidthMeter, + DEFAULT_MAX_INITIAL_BITRATE, DEFAULT_MIN_DURATION_FOR_QUALITY_INCREASE_MS, DEFAULT_MAX_DURATION_FOR_QUALITY_DECREASE_MS, DEFAULT_MIN_DURATION_TO_RETAIN_AFTER_DISCARD_MS, DEFAULT_BANDWIDTH_FRACTION, - DEFAULT_BUFFERED_FRACTION_TO_LIVE_EDGE_FOR_QUALITY_INCREASE); + DEFAULT_BUFFERED_FRACTION_TO_LIVE_EDGE_FOR_QUALITY_INCREASE, + DEFAULT_MIN_TIME_BETWEEN_BUFFER_REEVALUTATION_MS, + Clock.DEFAULT); } /** @@ -172,23 +216,35 @@ public class AdaptiveTrackSelection extends BaseTrackSelection { * @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 track of significantly 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 + * 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. * @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. + * 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 minTimeBetweenBufferReevaluationMs The track selection may periodically reevaluate its + * buffer and discard some chunks of lower quality to improve the playback quality if network + * condition has changed. This is the minimum duration between 2 consecutive buffer + * reevaluation calls. */ - public AdaptiveTrackSelection(TrackGroup group, int[] tracks, BandwidthMeter bandwidthMeter, - int maxInitialBitrate, long minDurationForQualityIncreaseMs, - long maxDurationForQualityDecreaseMs, long minDurationToRetainAfterDiscardMs, - float bandwidthFraction, float bufferedFractionToLiveEdgeForQualityIncrease) { + public AdaptiveTrackSelection( + TrackGroup group, + int[] tracks, + BandwidthMeter bandwidthMeter, + int maxInitialBitrate, + long minDurationForQualityIncreaseMs, + long maxDurationForQualityDecreaseMs, + long minDurationToRetainAfterDiscardMs, + float bandwidthFraction, + float bufferedFractionToLiveEdgeForQualityIncrease, + long minTimeBetweenBufferReevaluationMs, + Clock clock) { super(group, tracks); this.bandwidthMeter = bandwidthMeter; this.maxInitialBitrate = maxInitialBitrate; @@ -198,9 +254,17 @@ public class AdaptiveTrackSelection extends BaseTrackSelection { this.bandwidthFraction = bandwidthFraction; this.bufferedFractionToLiveEdgeForQualityIncrease = bufferedFractionToLiveEdgeForQualityIncrease; + this.minTimeBetweenBufferReevaluationMs = minTimeBetweenBufferReevaluationMs; + this.clock = clock; playbackSpeed = 1f; selectedIndex = determineIdealSelectedIndex(Long.MIN_VALUE); reason = C.SELECTION_REASON_INITIAL; + lastBufferEvaluationMs = C.TIME_UNSET; + } + + @Override + public void enable() { + lastBufferEvaluationMs = C.TIME_UNSET; } @Override @@ -211,7 +275,7 @@ public class AdaptiveTrackSelection extends BaseTrackSelection { @Override public void updateSelectedTrack(long playbackPositionUs, long bufferedDurationUs, long availableDurationUs) { - long nowMs = SystemClock.elapsedRealtime(); + long nowMs = clock.elapsedRealtime(); // Stash the current selection, then make a new one. int currentSelectedIndex = selectedIndex; selectedIndex = determineIdealSelectedIndex(nowMs); @@ -258,17 +322,25 @@ public class AdaptiveTrackSelection extends BaseTrackSelection { @Override public int evaluateQueueSize(long playbackPositionUs, List queue) { + long nowMs = clock.elapsedRealtime(); + if (lastBufferEvaluationMs != C.TIME_UNSET + && nowMs - lastBufferEvaluationMs < minTimeBetweenBufferReevaluationMs) { + return queue.size(); + } + lastBufferEvaluationMs = nowMs; if (queue.isEmpty()) { return 0; } + int queueSize = queue.size(); - long mediaBufferedDurationUs = queue.get(queueSize - 1).endTimeUs - playbackPositionUs; - long playoutBufferedDurationUs = - Util.getPlayoutDurationForMediaDuration(mediaBufferedDurationUs, playbackSpeed); - if (playoutBufferedDurationUs < minDurationToRetainAfterDiscardUs) { + MediaChunk lastChunk = queue.get(queueSize - 1); + long playoutBufferedDurationBeforeLastChunkUs = + Util.getPlayoutDurationForMediaDuration( + lastChunk.startTimeUs - playbackPositionUs, playbackSpeed); + if (playoutBufferedDurationBeforeLastChunkUs < minDurationToRetainAfterDiscardUs) { return queueSize; } - int idealSelectedIndex = determineIdealSelectedIndex(SystemClock.elapsedRealtime()); + int idealSelectedIndex = determineIdealSelectedIndex(nowMs); Format idealFormat = getFormat(idealSelectedIndex); // If the chunks contain video, discard from the first SD chunk beyond // minDurationToRetainAfterDiscardUs whose resolution and bitrate are both lower than the ideal @@ -293,8 +365,8 @@ public class AdaptiveTrackSelection extends BaseTrackSelection { /** * Computes the ideal selected index ignoring buffer health. * - * @param nowMs The current time in the timebase of {@link SystemClock#elapsedRealtime()}, or - * {@link Long#MIN_VALUE} to ignore blacklisting. + * @param nowMs The current time in the timebase of {@link Clock#elapsedRealtime()}, or {@link + * Long#MIN_VALUE} to ignore blacklisting. */ private int determineIdealSelectedIndex(long nowMs) { long bitrateEstimate = bandwidthMeter.getBitrateEstimate(); diff --git a/library/core/src/test/java/com/google/android/exoplayer2/source/CompositeSequenceableLoaderTest.java b/library/core/src/test/java/com/google/android/exoplayer2/source/CompositeSequenceableLoaderTest.java index e3ac104754..f7e29d2b06 100644 --- a/library/core/src/test/java/com/google/android/exoplayer2/source/CompositeSequenceableLoaderTest.java +++ b/library/core/src/test/java/com/google/android/exoplayer2/source/CompositeSequenceableLoaderTest.java @@ -265,6 +265,11 @@ public final class CompositeSequenceableLoaderTest { return loaded; } + @Override + public void reevaluateBuffer(long positionUs) { + // Do nothing. + } + private void setNextChunkDurationUs(int nextChunkDurationUs) { this.nextChunkDurationUs = nextChunkDurationUs; } diff --git a/library/core/src/test/java/com/google/android/exoplayer2/trackselection/AdaptiveTrackSelectionTest.java b/library/core/src/test/java/com/google/android/exoplayer2/trackselection/AdaptiveTrackSelectionTest.java new file mode 100644 index 0000000000..ea19c72826 --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/trackselection/AdaptiveTrackSelectionTest.java @@ -0,0 +1,428 @@ +/* + * Copyright (C) 2017 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.google.android.exoplayer2.trackselection; + +import static com.google.common.truth.Truth.assertThat; +import static org.mockito.Mockito.when; +import static org.mockito.MockitoAnnotations.initMocks; + +import android.net.Uri; +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.source.TrackGroup; +import com.google.android.exoplayer2.source.chunk.MediaChunk; +import com.google.android.exoplayer2.testutil.FakeClock; +import com.google.android.exoplayer2.upstream.BandwidthMeter; +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.upstream.DefaultHttpDataSource; +import com.google.android.exoplayer2.util.MimeTypes; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +/** Unit test for {@link AdaptiveTrackSelection}. */ +@RunWith(RobolectricTestRunner.class) +@Config(sdk = Config.TARGET_SDK, manifest = Config.NONE) +public final class AdaptiveTrackSelectionTest { + + @Mock private BandwidthMeter mockBandwidthMeter; + private FakeClock fakeClock; + + private AdaptiveTrackSelection adaptiveTrackSelection; + + @Before + public void setUp() { + initMocks(this); + fakeClock = new FakeClock(0); + when(mockBandwidthMeter.getBitrateEstimate()).thenReturn(BandwidthMeter.NO_ESTIMATE); + } + + @Test + public void testSelectInitialIndexUseMaxInitialBitrateIfNoBandwidthEstimate() { + 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 = adaptiveTrackSelection(trackGroup, /* initialBitrate= */ 1000); + + assertThat(adaptiveTrackSelection.getSelectedFormat()).isEqualTo(format2); + assertThat(adaptiveTrackSelection.getSelectionReason()).isEqualTo(C.SELECTION_REASON_INITIAL); + } + + @Test + public void testSelectInitialIndexUseBandwidthEstimateIfAvailable() { + 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(500L); + + adaptiveTrackSelection = adaptiveTrackSelection(trackGroup, /* initialBitrate= */ 1000); + + assertThat(adaptiveTrackSelection.getSelectedFormat()).isEqualTo(format1); + assertThat(adaptiveTrackSelection.getSelectionReason()).isEqualTo(C.SELECTION_REASON_INITIAL); + } + + @Test + public void testUpdateSelectedTrackDoNotSwitchUpIfNotBufferedEnough() { + 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); + + // initially bandwidth meter does not have any estimation. The second measurement onward returns + // 2000L, which prompts the track selection to switch up if possible. + when(mockBandwidthMeter.getBitrateEstimate()).thenReturn(BandwidthMeter.NO_ESTIMATE, 2000L); + + adaptiveTrackSelection = + adaptiveTrackSelectionWithMinDurationForQualityIncreaseMs( + trackGroup, /* initialBitrate= */ 1000, /* minDurationForQualityIncreaseMs= */ 10_000); + + adaptiveTrackSelection.updateSelectedTrack( + /* playbackPositionUs= */ 0, + /* bufferedDurationUs= */ 9_999_000, + /* availableDurationUs= */ C.TIME_UNSET); + + // When bandwidth estimation is updated to 2000L, we can switch up to use a higher bitrate + // format. However, since we only buffered 9_999_000 us, which is smaller than + // minDurationForQualityIncreaseMs, we should defer switch up. + assertThat(adaptiveTrackSelection.getSelectedFormat()).isEqualTo(format2); + assertThat(adaptiveTrackSelection.getSelectionReason()).isEqualTo(C.SELECTION_REASON_INITIAL); + } + + @Test + public void testUpdateSelectedTrackSwitchUpIfBufferedEnough() { + 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); + + // initially bandwidth meter does not have any estimation. The second measurement onward returns + // 2000L, which prompts the track selection to switch up if possible. + when(mockBandwidthMeter.getBitrateEstimate()).thenReturn(BandwidthMeter.NO_ESTIMATE, 2000L); + + adaptiveTrackSelection = + adaptiveTrackSelectionWithMinDurationForQualityIncreaseMs( + trackGroup, /* initialBitrate= */ 1000, /* minDurationForQualityIncreaseMs= */ 10_000); + + adaptiveTrackSelection.updateSelectedTrack( + /* playbackPositionUs= */ 0, + /* bufferedDurationUs= */ 10_000_000, + /* availableDurationUs= */ C.TIME_UNSET); + + // When bandwidth estimation is updated to 2000L, we can switch up to use a higher bitrate + // format. When we have buffered enough (10_000_000 us, which is equal to + // minDurationForQualityIncreaseMs), we should switch up now. + assertThat(adaptiveTrackSelection.getSelectedFormat()).isEqualTo(format3); + assertThat(adaptiveTrackSelection.getSelectionReason()).isEqualTo(C.SELECTION_REASON_ADAPTIVE); + } + + @Test + public void testUpdateSelectedTrackDoNotSwitchDownIfBufferedEnough() { + 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); + + // initially bandwidth meter does not have any estimation. The second measurement onward returns + // 500L, which prompts the track selection to switch down if necessary. + when(mockBandwidthMeter.getBitrateEstimate()).thenReturn(BandwidthMeter.NO_ESTIMATE, 500L); + + adaptiveTrackSelection = + adaptiveTrackSelectionWithMaxDurationForQualityDecreaseMs( + trackGroup, /* initialBitrate= */ 1000, /* maxDurationForQualityDecreaseMs= */ 25_000); + + adaptiveTrackSelection.updateSelectedTrack( + /* playbackPositionUs= */ 0, + /* bufferedDurationUs= */ 25_000_000, + /* availableDurationUs= */ C.TIME_UNSET); + + // When bandwidth estimation is updated to 500L, we should switch down to use a lower bitrate + // format. However, since we have enough buffer at higher quality (25_000_000 us, which is equal + // to maxDurationForQualityDecreaseMs), we should defer switch down. + assertThat(adaptiveTrackSelection.getSelectedFormat()).isEqualTo(format2); + assertThat(adaptiveTrackSelection.getSelectionReason()).isEqualTo(C.SELECTION_REASON_INITIAL); + } + + @Test + public void testUpdateSelectedTrackSwitchDownIfNotBufferedEnough() { + 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); + + // initially bandwidth meter does not have any estimation. The second measurement onward returns + // 500L, which prompts the track selection to switch down if necessary. + when(mockBandwidthMeter.getBitrateEstimate()).thenReturn(BandwidthMeter.NO_ESTIMATE, 500L); + + adaptiveTrackSelection = + adaptiveTrackSelectionWithMaxDurationForQualityDecreaseMs( + trackGroup, /* initialBitrate= */ 1000, /* maxDurationForQualityDecreaseMs= */ 25_000); + + adaptiveTrackSelection.updateSelectedTrack( + /* playbackPositionUs= */ 0, + /* bufferedDurationUs= */ 24_999_000, + /* availableDurationUs= */ C.TIME_UNSET); + + // When bandwidth estimation is updated to 500L, we should switch down to use a lower bitrate + // format. When we don't have enough buffer at higher quality (24_999_000 us is smaller than + // maxDurationForQualityDecreaseMs), we should switch down now. + assertThat(adaptiveTrackSelection.getSelectedFormat()).isEqualTo(format1); + assertThat(adaptiveTrackSelection.getSelectionReason()).isEqualTo(C.SELECTION_REASON_ADAPTIVE); + } + + @Test + public void testEvaluateQueueSizeReturnQueueSizeIfBandwidthIsNotImproved() { + 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); + + FakeMediaChunk chunk1 = + new FakeMediaChunk(format1, /* startTimeUs= */ 0, /* endTimeUs= */ 10_000_000); + FakeMediaChunk chunk2 = + new FakeMediaChunk(format1, /* startTimeUs= */ 10_000_000, /* endTimeUs= */ 20_000_000); + FakeMediaChunk chunk3 = + new FakeMediaChunk(format1, /* startTimeUs= */ 20_000_000, /* endTimeUs= */ 30_000_000); + List queue = new ArrayList<>(); + queue.add(chunk1); + queue.add(chunk2); + queue.add(chunk3); + + when(mockBandwidthMeter.getBitrateEstimate()).thenReturn(500L); + adaptiveTrackSelection = adaptiveTrackSelection(trackGroup, /* initialBitrate= */ 1000); + + int size = adaptiveTrackSelection.evaluateQueueSize(0, queue); + assertThat(size).isEqualTo(3); + } + + @Test + public void testEvaluateQueueSizeDoNotReevaluateUntilAfterMinTimeBetweenBufferReevaluation() { + 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); + + FakeMediaChunk chunk1 = + new FakeMediaChunk(format1, /* startTimeUs= */ 0, /* endTimeUs= */ 10_000_000); + FakeMediaChunk chunk2 = + new FakeMediaChunk(format1, /* startTimeUs= */ 10_000_000, /* endTimeUs= */ 20_000_000); + FakeMediaChunk chunk3 = + new FakeMediaChunk(format1, /* startTimeUs= */ 20_000_000, /* endTimeUs= */ 30_000_000); + List queue = new ArrayList<>(); + queue.add(chunk1); + queue.add(chunk2); + queue.add(chunk3); + + when(mockBandwidthMeter.getBitrateEstimate()).thenReturn(500L); + adaptiveTrackSelection = + adaptiveTrackSelectionWithMinTimeBetweenBufferReevaluationMs( + trackGroup, + /* initialBitrate= */ 1000, + /* durationToRetainAfterDiscardMs= */ 15_000, + /* minTimeBetweenBufferReevaluationMs= */ 2000); + + int initialQueueSize = adaptiveTrackSelection.evaluateQueueSize(0, queue); + + fakeClock.advanceTime(1999); + when(mockBandwidthMeter.getBitrateEstimate()).thenReturn(1000L); + + // When bandwidth estimation is updated, we can discard chunks at the end of the queue now. + // However, since min duration between buffer reevaluation = 2000, we will not reevaluate + // queue size if time now is only 1999 ms after last buffer reevaluation. + int newSize = adaptiveTrackSelection.evaluateQueueSize(0, queue); + assertThat(newSize).isEqualTo(initialQueueSize); + } + + @Test + public void testEvaluateQueueSizeRetainMoreThanMinimumDurationAfterDiscard() { + 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); + + FakeMediaChunk chunk1 = + new FakeMediaChunk(format1, /* startTimeUs= */ 0, /* endTimeUs= */ 10_000_000); + FakeMediaChunk chunk2 = + new FakeMediaChunk(format1, /* startTimeUs= */ 10_000_000, /* endTimeUs= */ 20_000_000); + FakeMediaChunk chunk3 = + new FakeMediaChunk(format1, /* startTimeUs= */ 20_000_000, /* endTimeUs= */ 30_000_000); + List queue = new ArrayList<>(); + queue.add(chunk1); + queue.add(chunk2); + queue.add(chunk3); + + when(mockBandwidthMeter.getBitrateEstimate()).thenReturn(500L); + adaptiveTrackSelection = + adaptiveTrackSelectionWithMinTimeBetweenBufferReevaluationMs( + trackGroup, + /* initialBitrate= */ 1000, + /* durationToRetainAfterDiscardMs= */ 15_000, + /* minTimeBetweenBufferReevaluationMs= */ 2000); + + int initialQueueSize = adaptiveTrackSelection.evaluateQueueSize(0, queue); + assertThat(initialQueueSize).isEqualTo(3); + + fakeClock.advanceTime(2000); + when(mockBandwidthMeter.getBitrateEstimate()).thenReturn(1000L); + + // When bandwidth estimation is updated and time has advanced enough, we can discard chunks at + // the end of the queue now. + // However, since duration to retain after discard = 15 000 ms, we need to retain at least the + // first 2 chunks + int newSize = adaptiveTrackSelection.evaluateQueueSize(0, queue); + assertThat(newSize).isEqualTo(2); + } + + private AdaptiveTrackSelection adaptiveTrackSelection(TrackGroup trackGroup, int initialBitrate) { + return new AdaptiveTrackSelection( + trackGroup, + selectedAllTracksInGroup(trackGroup), + mockBandwidthMeter, + initialBitrate, + 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, + /* bandwidthFraction= */ 1.0f, + AdaptiveTrackSelection.DEFAULT_BUFFERED_FRACTION_TO_LIVE_EDGE_FOR_QUALITY_INCREASE, + AdaptiveTrackSelection.DEFAULT_MIN_TIME_BETWEEN_BUFFER_REEVALUTATION_MS, + fakeClock); + } + + private AdaptiveTrackSelection adaptiveTrackSelectionWithMinDurationForQualityIncreaseMs( + TrackGroup trackGroup, int initialBitrate, long minDurationForQualityIncreaseMs) { + return new AdaptiveTrackSelection( + trackGroup, + selectedAllTracksInGroup(trackGroup), + mockBandwidthMeter, + initialBitrate, + minDurationForQualityIncreaseMs, + AdaptiveTrackSelection.DEFAULT_MAX_DURATION_FOR_QUALITY_DECREASE_MS, + AdaptiveTrackSelection.DEFAULT_MIN_DURATION_TO_RETAIN_AFTER_DISCARD_MS, + /* bandwidthFraction= */ 1.0f, + AdaptiveTrackSelection.DEFAULT_BUFFERED_FRACTION_TO_LIVE_EDGE_FOR_QUALITY_INCREASE, + AdaptiveTrackSelection.DEFAULT_MIN_TIME_BETWEEN_BUFFER_REEVALUTATION_MS, + fakeClock); + } + + private AdaptiveTrackSelection adaptiveTrackSelectionWithMaxDurationForQualityDecreaseMs( + TrackGroup trackGroup, int initialBitrate, long maxDurationForQualityDecreaseMs) { + return new AdaptiveTrackSelection( + trackGroup, + selectedAllTracksInGroup(trackGroup), + mockBandwidthMeter, + initialBitrate, + AdaptiveTrackSelection.DEFAULT_MIN_DURATION_FOR_QUALITY_INCREASE_MS, + maxDurationForQualityDecreaseMs, + AdaptiveTrackSelection.DEFAULT_MIN_DURATION_TO_RETAIN_AFTER_DISCARD_MS, + /* bandwidthFraction= */ 1.0f, + AdaptiveTrackSelection.DEFAULT_BUFFERED_FRACTION_TO_LIVE_EDGE_FOR_QUALITY_INCREASE, + AdaptiveTrackSelection.DEFAULT_MIN_TIME_BETWEEN_BUFFER_REEVALUTATION_MS, + fakeClock); + } + + private AdaptiveTrackSelection adaptiveTrackSelectionWithMinTimeBetweenBufferReevaluationMs( + TrackGroup trackGroup, + int initialBitrate, + long durationToRetainAfterDiscardMs, + long minTimeBetweenBufferReevaluationMs) { + return new AdaptiveTrackSelection( + trackGroup, + selectedAllTracksInGroup(trackGroup), + mockBandwidthMeter, + initialBitrate, + AdaptiveTrackSelection.DEFAULT_MIN_DURATION_FOR_QUALITY_INCREASE_MS, + AdaptiveTrackSelection.DEFAULT_MAX_DURATION_FOR_QUALITY_DECREASE_MS, + durationToRetainAfterDiscardMs, + /* bandwidth fraction= */ 1.0f, + AdaptiveTrackSelection.DEFAULT_BUFFERED_FRACTION_TO_LIVE_EDGE_FOR_QUALITY_INCREASE, + minTimeBetweenBufferReevaluationMs, + fakeClock); + } + + private int[] selectedAllTracksInGroup(TrackGroup trackGroup) { + int[] listIndices = new int[trackGroup.length]; + for (int i = 0; i < trackGroup.length; i++) { + listIndices[i] = i; + } + return listIndices; + } + + private static Format videoFormat(int bitrate, int width, int height) { + return Format.createVideoSampleFormat( + /* id= */ null, + /* sampleMimeType= */ MimeTypes.VIDEO_H264, + /* codecs= */ null, + /* bitrate= */ bitrate, + /* maxInputSize= */ Format.NO_VALUE, + /* width= */ width, + /* height= */ height, + /* frameRate= */ Format.NO_VALUE, + /* initializationData= */ null, + /* drmInitData= */ null); + } + + private static final class FakeMediaChunk extends MediaChunk { + + private static final DataSource DATA_SOURCE = new DefaultHttpDataSource("TEST_AGENT", null); + + public FakeMediaChunk(Format trackFormat, long startTimeUs, long endTimeUs) { + super( + DATA_SOURCE, + new DataSpec(Uri.EMPTY), + trackFormat, + C.SELECTION_REASON_ADAPTIVE, + null, + startTimeUs, + endTimeUs, + 0); + } + + @Override + public void cancelLoad() { + // Do nothing. + } + + @Override + public boolean isLoadCanceled() { + return false; + } + + @Override + public void load() throws IOException, InterruptedException { + // Do nothing. + } + + @Override + public boolean isLoadCompleted() { + return true; + } + + @Override + public long bytesLoaded() { + return 0; + } + } +} diff --git a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaPeriod.java b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaPeriod.java index 8fe10e94ee..f320ad2844 100644 --- a/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaPeriod.java +++ b/library/dash/src/main/java/com/google/android/exoplayer2/source/dash/DashMediaPeriod.java @@ -270,6 +270,11 @@ import java.util.Map; } } + @Override + public void reevaluateBuffer(long positionUs) { + compositeSequenceableLoader.reevaluateBuffer(positionUs); + } + @Override public boolean continueLoading(long positionUs) { return compositeSequenceableLoader.continueLoading(positionUs); diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriod.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriod.java index dd596878d2..fd8f2bdbe9 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriod.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsMediaPeriod.java @@ -195,6 +195,11 @@ public final class HlsMediaPeriod implements MediaPeriod, HlsSampleStreamWrapper } } + @Override + public void reevaluateBuffer(long positionUs) { + compositeSequenceableLoader.reevaluateBuffer(positionUs); + } + @Override public boolean continueLoading(long positionUs) { return compositeSequenceableLoader.continueLoading(positionUs); diff --git a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java index dbb71329c5..2e69e41d30 100644 --- a/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java +++ b/library/hls/src/main/java/com/google/android/exoplayer2/source/hls/HlsSampleStreamWrapper.java @@ -524,6 +524,11 @@ import java.util.Arrays; return true; } + @Override + public void reevaluateBuffer(long positionUs) { + // Do nothing. + } + // Loader.Callback implementation. @Override diff --git a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaPeriod.java b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaPeriod.java index d418a21dff..564993befe 100644 --- a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaPeriod.java +++ b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/SsMediaPeriod.java @@ -149,6 +149,11 @@ import java.util.ArrayList; } } + @Override + public void reevaluateBuffer(long positionUs) { + compositeSequenceableLoader.reevaluateBuffer(positionUs); + } + @Override public boolean continueLoading(long positionUs) { return compositeSequenceableLoader.continueLoading(positionUs); diff --git a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/manifest/SsManifest.java b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/manifest/SsManifest.java index fbc3726a0e..0df180a5a6 100644 --- a/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/manifest/SsManifest.java +++ b/library/smoothstreaming/src/main/java/com/google/android/exoplayer2/source/smoothstreaming/manifest/SsManifest.java @@ -203,8 +203,20 @@ public class SsManifest { long timescale, String name, int maxWidth, int maxHeight, int displayWidth, int displayHeight, String language, Format[] formats, List chunkStartTimes, long lastChunkDuration) { - this (baseUri, chunkTemplate, type, subType, timescale, name, maxWidth, maxHeight, - displayWidth, displayHeight, language, formats, chunkStartTimes, + this( + baseUri, + chunkTemplate, + type, + subType, + timescale, + name, + maxWidth, + maxHeight, + displayWidth, + displayHeight, + language, + formats, + chunkStartTimes, Util.scaleLargeTimestamps(chunkStartTimes, C.MICROS_PER_SECOND, timescale), Util.scaleLargeTimestamp(lastChunkDuration, C.MICROS_PER_SECOND, timescale)); } diff --git a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaPeriod.java b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaPeriod.java index c1be199b1e..d34c1d1c0c 100644 --- a/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaPeriod.java +++ b/testutils/src/main/java/com/google/android/exoplayer2/testutil/FakeMediaPeriod.java @@ -151,6 +151,11 @@ public class FakeMediaPeriod implements MediaPeriod { // Do nothing. } + @Override + public void reevaluateBuffer(long positionUs) { + // Do nothing. + } + @Override public long readDiscontinuity() { Assert.assertTrue(prepared);