Compare commits

...

3 Commits

Author SHA1 Message Date
bachinger
d0833c4e7c Make HlsInterstitialsAdsLoader resumable
This allows the app to store the ad playback state and then
when starting again, allow them to resume with the same
ad playback state. This way, users don't have to watch the same
ads twice if/when playback was interrupted. For instance, when the
app was put into background and then is foregrounded again.

PiperOrigin-RevId: 748737832
2025-04-17 10:56:14 -07:00
tianyifeng
7f6ddef502 Enable DownloadHelper to create DownloadRequest with timeRange
PiperOrigin-RevId: 748722156
2025-04-17 10:14:04 -07:00
tonihei
c4c3e5e0c8 Move getMaximumEncodedRateBytesPerSecond to a shared public util
This makes it more easily reusable.

Issue: androidx/media#2339
PiperOrigin-RevId: 748713531
2025-04-17 09:52:18 -07:00
13 changed files with 840 additions and 98 deletions

View File

@ -69,6 +69,7 @@
* Cronet extension: * Cronet extension:
* RTMP extension: * RTMP extension:
* HLS extension: * HLS extension:
* Support X-ASSET-LIST and live streams with `HlsInterstitialsAdsLoader`.
* DASH extension: * DASH extension:
* Smooth Streaming extension: * Smooth Streaming extension:
* RTSP extension: * RTSP extension:

View File

@ -596,6 +596,21 @@ public final class AdPlaybackState {
return C.INDEX_UNSET; return C.INDEX_UNSET;
} }
/** Returns a safe copy with all array fields copied into the new instance as new arrays. */
public AdGroup copy() {
return new AdGroup(
timeUs,
count,
originalCount,
Arrays.copyOf(states, states.length),
Arrays.copyOf(mediaItems, mediaItems.length),
Arrays.copyOf(durationsUs, durationsUs.length),
contentResumeOffsetUs,
isServerSideInserted,
Arrays.copyOf(ids, ids.length),
isPlaceholder);
}
@CheckResult @CheckResult
private static @AdState int[] copyStatesWithSpaceForAdCount(@AdState int[] states, int count) { private static @AdState int[] copyStatesWithSpaceForAdCount(@AdState int[] states, int count) {
int oldStateCount = states.length; int oldStateCount = states.length;
@ -944,6 +959,20 @@ public final class AdPlaybackState {
adsId, adGroups, adResumePositionUs, contentDurationUs, removedAdGroupCount); adsId, adGroups, adResumePositionUs, contentDurationUs, removedAdGroupCount);
} }
/**
* Returns an new instance that is a safe deep copy of this instance in case an immutable object
* is used for {@link #adsId}.
*/
@CheckResult
public AdPlaybackState copy() {
AdGroup[] adGroups = new AdGroup[this.adGroups.length];
for (int i = 0; i < adGroups.length; i++) {
adGroups[i] = this.adGroups[i].copy();
}
return new AdPlaybackState(
adsId, adGroups, adResumePositionUs, contentDurationUs, removedAdGroupCount);
}
/** /**
* @deprecated Use {@link #withAvailableAdMediaItem} instead. * @deprecated Use {@link #withAvailableAdMediaItem} instead.
*/ */

View File

@ -26,6 +26,7 @@ import static org.junit.Assert.fail;
import android.net.Uri; import android.net.Uri;
import android.os.Bundle; import android.os.Bundle;
import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.ext.junit.runners.AndroidJUnit4;
import java.lang.reflect.Field;
import org.junit.Assert; import org.junit.Assert;
import org.junit.Test; import org.junit.Test;
import org.junit.runner.RunWith; import org.junit.runner.RunWith;
@ -1201,4 +1202,65 @@ public class AdPlaybackStateTest {
assertThat(adPlaybackState.getAdGroup(/* adGroupIndex= */ 3).durationsUs).hasLength(0); assertThat(adPlaybackState.getAdGroup(/* adGroupIndex= */ 3).durationsUs).hasLength(0);
} }
@SuppressWarnings("deprecation") // testing deprecated field `uris`
@Test
public void copy() {
AdPlaybackState adPlaybackState =
new AdPlaybackState("adsId", 10_000L)
.withLivePostrollPlaceholderAppended(false)
.withAdCount(/* adGroupIndex= */ 0, 1)
.withAvailableAdMediaItem(
/* adGroupIndex= */ 0,
/* adIndexInAdGroup= */ 0,
MediaItem.fromUri("http://example.com/0-0"))
.withNewAdGroup(/* adGroupIndex= */ 1, 11_000)
.withAdCount(/* adGroupIndex= */ 1, 2)
.withAvailableAdMediaItem(
/* adGroupIndex= */ 1,
/* adIndexInAdGroup= */ 0,
MediaItem.fromUri("http://example.com/1-0"))
.withAvailableAdMediaItem(
/* adGroupIndex= */ 1,
/* adIndexInAdGroup= */ 1,
MediaItem.fromUri("http://example.com/1-1"))
.withNewAdGroup(/* adGroupIndex= */ 2, 12_000);
AdPlaybackState copy = adPlaybackState.copy();
assertThat(copy).isEqualTo(adPlaybackState);
assertThat(copy).isNotSameInstanceAs(adPlaybackState);
for (int adGroupIndex = 0; adGroupIndex < adPlaybackState.adGroupCount; adGroupIndex++) {
AdPlaybackState.AdGroup adGroupCopy = copy.getAdGroup(adGroupIndex);
AdPlaybackState.AdGroup originalAdGroup = adPlaybackState.getAdGroup(adGroupIndex);
assertThat(adGroupCopy).isNotSameInstanceAs(originalAdGroup);
assertThat(adGroupCopy.durationsUs).isNotSameInstanceAs(originalAdGroup.durationsUs);
assertThat(adGroupCopy.ids).isNotSameInstanceAs(originalAdGroup.ids);
assertThat(adGroupCopy.mediaItems).isNotSameInstanceAs(originalAdGroup.mediaItems);
assertThat(adGroupCopy.states).isNotSameInstanceAs(originalAdGroup.states);
assertThat(adGroupCopy.uris).isNotSameInstanceAs(originalAdGroup.uris);
}
}
/**
* If this test fails a new field of type array has been added to {@link AdPlaybackState.AdGroup}.
* Make sure to update {@link AdPlaybackState.AdGroup#copy} and add a line in the test {@link
* #copy()} to verify that the new array field has been copied as a new array instance. Then
* increment the expected count in this test case.
*/
@Test
public void adGroup_numberOfFieldsOfTypeArray_hasNotChanged() {
// 5 fields of type array durationsUs, ids, mediaItems, states, uris.
int expectedNumberOfFieldsOfTypeArray = 5;
Class<?> clazz = AdPlaybackState.AdGroup.class;
Field[] fields = clazz.getFields();
int arrayFieldCount = 0;
for (Field field : fields) {
if (field.getType().isArray()) {
arrayFieldCount++;
}
}
assertThat(arrayFieldCount).isEqualTo(expectedNumberOfFieldsOfTypeArray);
}
} }

View File

@ -63,6 +63,7 @@ import androidx.media3.extractor.AacUtil;
import androidx.media3.extractor.Ac3Util; import androidx.media3.extractor.Ac3Util;
import androidx.media3.extractor.Ac4Util; import androidx.media3.extractor.Ac4Util;
import androidx.media3.extractor.DtsUtil; import androidx.media3.extractor.DtsUtil;
import androidx.media3.extractor.ExtractorUtil;
import androidx.media3.extractor.MpegAudioUtil; import androidx.media3.extractor.MpegAudioUtil;
import androidx.media3.extractor.OpusUtil; import androidx.media3.extractor.OpusUtil;
import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableList;
@ -1479,8 +1480,7 @@ public final class DefaultAudioSink implements AudioSink {
long byteRate = long byteRate =
configuration.outputMode == OUTPUT_MODE_PCM configuration.outputMode == OUTPUT_MODE_PCM
? (long) configuration.outputSampleRate * configuration.outputPcmFrameSize ? (long) configuration.outputSampleRate * configuration.outputPcmFrameSize
: DefaultAudioTrackBufferSizeProvider.getMaximumEncodedRateBytesPerSecond( : getNonPcmMaximumEncodedRateBytesPerSecond(configuration.outputEncoding);
configuration.outputEncoding);
return Util.scaleLargeValue( return Util.scaleLargeValue(
configuration.bufferSize, C.MICROS_PER_SECOND, byteRate, RoundingMode.DOWN); configuration.bufferSize, C.MICROS_PER_SECOND, byteRate, RoundingMode.DOWN);
} }
@ -2387,6 +2387,12 @@ public final class DefaultAudioSink implements AudioSink {
} }
} }
private static int getNonPcmMaximumEncodedRateBytesPerSecond(@C.Encoding int encoding) {
int rate = ExtractorUtil.getMaximumEncodedRateBytesPerSecond(encoding);
checkState(rate != C.RATE_UNSET_INT);
return rate;
}
@RequiresApi(23) @RequiresApi(23)
private static final class Api23 { private static final class Api23 {
private Api23() {} private Api23() {}
@ -2404,8 +2410,7 @@ public final class DefaultAudioSink implements AudioSink {
: Util.scaleLargeValue( : Util.scaleLargeValue(
audioTrack.getBufferSizeInFrames(), audioTrack.getBufferSizeInFrames(),
C.MICROS_PER_SECOND, C.MICROS_PER_SECOND,
DefaultAudioTrackBufferSizeProvider.getMaximumEncodedRateBytesPerSecond( getNonPcmMaximumEncodedRateBytesPerSecond(configuration.outputEncoding),
configuration.outputEncoding),
RoundingMode.DOWN); RoundingMode.DOWN);
} }
} }

View File

@ -15,6 +15,7 @@
*/ */
package androidx.media3.exoplayer.audio; package androidx.media3.exoplayer.audio;
import static androidx.media3.common.util.Assertions.checkState;
import static androidx.media3.common.util.Util.constrainValue; import static androidx.media3.common.util.Util.constrainValue;
import static androidx.media3.exoplayer.audio.DefaultAudioSink.OUTPUT_MODE_OFFLOAD; import static androidx.media3.exoplayer.audio.DefaultAudioSink.OUTPUT_MODE_OFFLOAD;
import static androidx.media3.exoplayer.audio.DefaultAudioSink.OUTPUT_MODE_PASSTHROUGH; import static androidx.media3.exoplayer.audio.DefaultAudioSink.OUTPUT_MODE_PASSTHROUGH;
@ -28,12 +29,7 @@ import androidx.media3.common.C;
import androidx.media3.common.Format; import androidx.media3.common.Format;
import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.UnstableApi;
import androidx.media3.exoplayer.audio.DefaultAudioSink.OutputMode; import androidx.media3.exoplayer.audio.DefaultAudioSink.OutputMode;
import androidx.media3.extractor.AacUtil; import androidx.media3.extractor.ExtractorUtil;
import androidx.media3.extractor.Ac3Util;
import androidx.media3.extractor.Ac4Util;
import androidx.media3.extractor.DtsUtil;
import androidx.media3.extractor.MpegAudioUtil;
import androidx.media3.extractor.OpusUtil;
import com.google.errorprone.annotations.CanIgnoreReturnValue; import com.google.errorprone.annotations.CanIgnoreReturnValue;
import java.math.RoundingMode; import java.math.RoundingMode;
@ -266,13 +262,13 @@ public class DefaultAudioTrackBufferSizeProvider
int byteRate = int byteRate =
bitrate != Format.NO_VALUE bitrate != Format.NO_VALUE
? divide(bitrate, 8, RoundingMode.CEILING) ? divide(bitrate, 8, RoundingMode.CEILING)
: getMaximumEncodedRateBytesPerSecond(encoding); : getNonPcmMaximumEncodedRateBytesPerSecond(encoding);
return checkedCast((long) bufferSizeUs * byteRate / C.MICROS_PER_SECOND); return checkedCast((long) bufferSizeUs * byteRate / C.MICROS_PER_SECOND);
} }
/** Returns the buffer size for offload playback. */ /** Returns the buffer size for offload playback. */
protected int getOffloadBufferSizeInBytes(@C.Encoding int encoding) { protected int getOffloadBufferSizeInBytes(@C.Encoding int encoding) {
int maxByteRate = getMaximumEncodedRateBytesPerSecond(encoding); int maxByteRate = getNonPcmMaximumEncodedRateBytesPerSecond(encoding);
return checkedCast((long) offloadBufferDurationUs * maxByteRate / C.MICROS_PER_SECOND); return checkedCast((long) offloadBufferDurationUs * maxByteRate / C.MICROS_PER_SECOND);
} }
@ -280,49 +276,9 @@ public class DefaultAudioTrackBufferSizeProvider
return checkedCast((long) durationUs * samplingRate * frameSize / C.MICROS_PER_SECOND); return checkedCast((long) durationUs * samplingRate * frameSize / C.MICROS_PER_SECOND);
} }
protected static int getMaximumEncodedRateBytesPerSecond(@C.Encoding int encoding) { private static int getNonPcmMaximumEncodedRateBytesPerSecond(@C.Encoding int encoding) {
switch (encoding) { int rate = ExtractorUtil.getMaximumEncodedRateBytesPerSecond(encoding);
case C.ENCODING_MP3: checkState(rate != C.RATE_UNSET_INT);
return MpegAudioUtil.MAX_RATE_BYTES_PER_SECOND; return rate;
case C.ENCODING_AAC_LC:
return AacUtil.AAC_LC_MAX_RATE_BYTES_PER_SECOND;
case C.ENCODING_AAC_HE_V1:
return AacUtil.AAC_HE_V1_MAX_RATE_BYTES_PER_SECOND;
case C.ENCODING_AAC_HE_V2:
return AacUtil.AAC_HE_V2_MAX_RATE_BYTES_PER_SECOND;
case C.ENCODING_AAC_XHE:
return AacUtil.AAC_XHE_MAX_RATE_BYTES_PER_SECOND;
case C.ENCODING_AAC_ELD:
return AacUtil.AAC_ELD_MAX_RATE_BYTES_PER_SECOND;
case C.ENCODING_AC3:
return Ac3Util.AC3_MAX_RATE_BYTES_PER_SECOND;
case C.ENCODING_E_AC3:
case C.ENCODING_E_AC3_JOC:
return Ac3Util.E_AC3_MAX_RATE_BYTES_PER_SECOND;
case C.ENCODING_AC4:
return Ac4Util.MAX_RATE_BYTES_PER_SECOND;
case C.ENCODING_DTS:
return DtsUtil.DTS_MAX_RATE_BYTES_PER_SECOND;
case C.ENCODING_DTS_HD:
case C.ENCODING_DTS_UHD_P2:
return DtsUtil.DTS_HD_MAX_RATE_BYTES_PER_SECOND;
case C.ENCODING_DOLBY_TRUEHD:
return Ac3Util.TRUEHD_MAX_RATE_BYTES_PER_SECOND;
case C.ENCODING_OPUS:
return OpusUtil.MAX_BYTES_PER_SECOND;
case C.ENCODING_PCM_16BIT:
case C.ENCODING_PCM_16BIT_BIG_ENDIAN:
case C.ENCODING_PCM_24BIT:
case C.ENCODING_PCM_24BIT_BIG_ENDIAN:
case C.ENCODING_PCM_32BIT:
case C.ENCODING_PCM_32BIT_BIG_ENDIAN:
case C.ENCODING_PCM_8BIT:
case C.ENCODING_PCM_FLOAT:
case C.ENCODING_AAC_ER_BSAC:
case C.ENCODING_INVALID:
case Format.NO_VALUE:
default:
throw new IllegalArgumentException();
}
} }
} }

View File

@ -830,7 +830,6 @@ public final class DownloadHelper {
* to, or {@link C#TIME_UNSET} if the download should cover to the end of the media. If the * to, or {@link C#TIME_UNSET} if the download should cover to the end of the media. If the
* {@code endPositionMs} is larger than the duration of the media, then the download will * {@code endPositionMs} is larger than the duration of the media, then the download will
* cover to the end of the media. * cover to the end of the media.
* @throws IllegalStateException If the media item is of type DASH, HLS or SmoothStreaming.
*/ */
public DownloadRequest getDownloadRequest( public DownloadRequest getDownloadRequest(
@Nullable byte[] data, long startPositionMs, long durationMs) { @Nullable byte[] data, long startPositionMs, long durationMs) {
@ -863,15 +862,11 @@ public final class DownloadHelper {
* {@link C#TIME_UNSET} if the download should cover to the end of the media. If the end * {@link C#TIME_UNSET} if the download should cover to the end of the media. If the end
* position resolved from {@code startPositionMs} and {@code durationMs} is beyond the * position resolved from {@code startPositionMs} and {@code durationMs} is beyond the
* duration of the media, then the download will just cover to the end of the media. * duration of the media, then the download will just cover to the end of the media.
* @throws IllegalStateException If the media item is of type DASH, HLS or SmoothStreaming.
*/ */
public DownloadRequest getDownloadRequest( public DownloadRequest getDownloadRequest(
String id, @Nullable byte[] data, long startPositionMs, long durationMs) { String id, @Nullable byte[] data, long startPositionMs, long durationMs) {
checkState(
mode == MODE_PREPARE_PROGRESSIVE_SOURCE,
"Partial download is only supported for progressive streams");
DownloadRequest.Builder builder = getDownloadRequestBuilder(id, data); DownloadRequest.Builder builder = getDownloadRequestBuilder(id, data);
assertPreparedWithProgressiveSource(); assertPreparedWithMedia();
populateDownloadRequestBuilderWithDownloadRange(builder, startPositionMs, durationMs); populateDownloadRequestBuilderWithDownloadRange(builder, startPositionMs, durationMs);
return builder.build(); return builder.build();
} }
@ -906,13 +901,22 @@ public final class DownloadHelper {
private void populateDownloadRequestBuilderWithDownloadRange( private void populateDownloadRequestBuilderWithDownloadRange(
DownloadRequest.Builder requestBuilder, long startPositionMs, long durationMs) { DownloadRequest.Builder requestBuilder, long startPositionMs, long durationMs) {
assertPreparedWithProgressiveSource(); switch (mode) {
Timeline timeline = mediaPreparer.timeline; case MODE_PREPARE_PROGRESSIVE_SOURCE:
if (mediaPreparer.mediaPeriods.length > 1) { populateDownloadRequestBuilderWithByteRange(requestBuilder, startPositionMs, durationMs);
Log.w(TAG, "Partial download is only supported for single period."); break;
return; case MODE_PREPARE_NON_PROGRESSIVE_SOURCE_AND_SELECT_TRACKS:
populateDownloadRequestBuilderWithTimeRange(requestBuilder, startPositionMs, durationMs);
break;
default:
break;
}
} }
private void populateDownloadRequestBuilderWithByteRange(
DownloadRequest.Builder requestBuilder, long startPositionMs, long durationMs) {
assertPreparedWithProgressiveSource();
Timeline timeline = mediaPreparer.timeline;
Timeline.Window window = new Timeline.Window(); Timeline.Window window = new Timeline.Window();
Timeline.Period period = new Timeline.Period(); Timeline.Period period = new Timeline.Period();
long periodStartPositionUs = long periodStartPositionUs =
@ -957,6 +961,25 @@ public final class DownloadHelper {
} }
} }
private void populateDownloadRequestBuilderWithTimeRange(
DownloadRequest.Builder requestBuilder, long startPositionMs, long durationMs) {
assertPreparedWithNonProgressiveSourceAndTracksSelected();
Timeline timeline = mediaPreparer.timeline;
Timeline.Window window = timeline.getWindow(0, new Timeline.Window());
long startPositionUs =
startPositionMs == C.TIME_UNSET
? window.getDefaultPositionUs()
: Util.msToUs(startPositionMs);
long windowDurationUs = window.getDurationUs();
long durationUs = durationMs == C.TIME_UNSET ? windowDurationUs : Util.msToUs(durationMs);
if (windowDurationUs != C.TIME_UNSET) {
startPositionUs = min(startPositionUs, windowDurationUs);
durationUs = min(durationUs, windowDurationUs - startPositionUs);
}
requestBuilder.setTimeRange(startPositionUs, durationUs);
}
@RequiresNonNull({ @RequiresNonNull({
"trackGroupArrays", "trackGroupArrays",
"trackSelectionsByPeriodAndRenderer", "trackSelectionsByPeriodAndRenderer",

View File

@ -17,7 +17,7 @@ package androidx.media3.exoplayer.audio;
import static androidx.media3.common.C.MICROS_PER_SECOND; import static androidx.media3.common.C.MICROS_PER_SECOND;
import static androidx.media3.exoplayer.audio.DefaultAudioSink.OUTPUT_MODE_PASSTHROUGH; import static androidx.media3.exoplayer.audio.DefaultAudioSink.OUTPUT_MODE_PASSTHROUGH;
import static androidx.media3.exoplayer.audio.DefaultAudioTrackBufferSizeProvider.getMaximumEncodedRateBytesPerSecond; import static androidx.media3.extractor.ExtractorUtil.getMaximumEncodedRateBytesPerSecond;
import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth.assertThat;
import androidx.media3.common.C; import androidx.media3.common.C;

View File

@ -17,7 +17,7 @@ package androidx.media3.exoplayer.audio;
import static androidx.media3.common.C.MICROS_PER_SECOND; import static androidx.media3.common.C.MICROS_PER_SECOND;
import static androidx.media3.exoplayer.audio.DefaultAudioSink.OUTPUT_MODE_PASSTHROUGH; import static androidx.media3.exoplayer.audio.DefaultAudioSink.OUTPUT_MODE_PASSTHROUGH;
import static androidx.media3.exoplayer.audio.DefaultAudioTrackBufferSizeProvider.getMaximumEncodedRateBytesPerSecond; import static androidx.media3.extractor.ExtractorUtil.getMaximumEncodedRateBytesPerSecond;
import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth.assertThat;
import androidx.media3.common.C; import androidx.media3.common.C;

View File

@ -17,7 +17,7 @@ package androidx.media3.exoplayer.audio;
import static androidx.media3.common.C.MICROS_PER_SECOND; import static androidx.media3.common.C.MICROS_PER_SECOND;
import static androidx.media3.exoplayer.audio.DefaultAudioSink.OUTPUT_MODE_PASSTHROUGH; import static androidx.media3.exoplayer.audio.DefaultAudioSink.OUTPUT_MODE_PASSTHROUGH;
import static androidx.media3.exoplayer.audio.DefaultAudioTrackBufferSizeProvider.getMaximumEncodedRateBytesPerSecond; import static androidx.media3.extractor.ExtractorUtil.getMaximumEncodedRateBytesPerSecond;
import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth.assertThat;
import androidx.media3.common.C; import androidx.media3.common.C;

View File

@ -18,7 +18,6 @@ package androidx.media3.exoplayer.offline;
import static androidx.test.core.app.ApplicationProvider.getApplicationContext; import static androidx.test.core.app.ApplicationProvider.getApplicationContext;
import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth.assertThat;
import static java.util.concurrent.TimeUnit.MILLISECONDS; import static java.util.concurrent.TimeUnit.MILLISECONDS;
import static org.junit.Assert.assertThrows;
import static org.robolectric.shadows.ShadowLooper.shadowMainLooper; import static org.robolectric.shadows.ShadowLooper.shadowMainLooper;
import android.content.Context; import android.content.Context;
@ -72,10 +71,15 @@ import org.junit.runner.RunWith;
public class DownloadHelperTest { public class DownloadHelperTest {
private static final Object TEST_MANIFEST = new Object(); private static final Object TEST_MANIFEST = new Object();
private static final long TEST_WINDOW_DEFAULT_POSITION_US = C.MICROS_PER_SECOND;
private static final Timeline TEST_TIMELINE = private static final Timeline TEST_TIMELINE =
new FakeTimeline( new FakeTimeline(
new Object[] {TEST_MANIFEST}, new Object[] {TEST_MANIFEST},
new TimelineWindowDefinition(/* periodCount= */ 2, /* id= */ new Object())); new TimelineWindowDefinition.Builder()
.setPeriodCount(2)
.setDefaultPositionUs(TEST_WINDOW_DEFAULT_POSITION_US)
.build());
private static TrackGroup trackGroupVideoLow; private static TrackGroup trackGroupVideoLow;
private static TrackGroup trackGroupVideoLowAndHigh; private static TrackGroup trackGroupVideoLowAndHigh;
@ -86,6 +90,7 @@ public class DownloadHelperTest {
private static TrackGroupArray[] trackGroupArrays; private static TrackGroupArray[] trackGroupArrays;
private static MediaItem testMediaItem; private static MediaItem testMediaItem;
private RenderersFactory renderersFactory;
private DownloadHelper downloadHelper; private DownloadHelper downloadHelper;
@BeforeClass @BeforeClass
@ -124,7 +129,7 @@ public class DownloadHelperTest {
FakeRenderer videoRenderer = new FakeRenderer(C.TRACK_TYPE_VIDEO); FakeRenderer videoRenderer = new FakeRenderer(C.TRACK_TYPE_VIDEO);
FakeRenderer audioRenderer = new FakeRenderer(C.TRACK_TYPE_AUDIO); FakeRenderer audioRenderer = new FakeRenderer(C.TRACK_TYPE_AUDIO);
FakeRenderer textRenderer = new FakeRenderer(C.TRACK_TYPE_TEXT); FakeRenderer textRenderer = new FakeRenderer(C.TRACK_TYPE_TEXT);
RenderersFactory renderersFactory = renderersFactory =
(handler, videoListener, audioListener, metadata, text) -> (handler, videoListener, audioListener, metadata, text) ->
new Renderer[] {textRenderer, audioRenderer, videoRenderer}; new Renderer[] {textRenderer, audioRenderer, videoRenderer};
@ -329,8 +334,8 @@ public class DownloadHelperTest {
prepareDownloadHelper(downloadHelper); prepareDownloadHelper(downloadHelper);
// Select parameters to require some merging of track groups because the new parameters add // Select parameters to require some merging of track groups because the new parameters add
// all video tracks to initial video single track selection. // all video tracks to initial video single track selection.
TrackSelectionParameters parameters = DefaultTrackSelector.Parameters parameters =
new TrackSelectionParameters.Builder(getApplicationContext()) new DefaultTrackSelector.Parameters.Builder()
.setPreferredAudioLanguage("de") .setPreferredAudioLanguage("de")
.setPreferredTextLanguage("en") .setPreferredTextLanguage("en")
.build(); .build();
@ -433,8 +438,8 @@ public class DownloadHelperTest {
prepareDownloadHelper(downloadHelper); prepareDownloadHelper(downloadHelper);
// Ensure we have track groups with multiple indices, renderers with multiple track groups and // Ensure we have track groups with multiple indices, renderers with multiple track groups and
// also renderers without any track groups. // also renderers without any track groups.
TrackSelectionParameters parameters = DefaultTrackSelector.Parameters parameters =
new TrackSelectionParameters.Builder(getApplicationContext()) new DefaultTrackSelector.Parameters.Builder()
.setPreferredAudioLanguage("de") .setPreferredAudioLanguage("de")
.setPreferredTextLanguage("en") .setPreferredTextLanguage("en")
.build(); .build();
@ -464,8 +469,8 @@ public class DownloadHelperTest {
throws Exception { throws Exception {
prepareDownloadHelper(downloadHelper); prepareDownloadHelper(downloadHelper);
TrackSelectionParameters parameters = DefaultTrackSelector.Parameters parameters =
new TrackSelectionParameters.Builder(getApplicationContext()) new DefaultTrackSelector.Parameters.Builder()
.addOverride(new TrackSelectionOverride(trackGroupAudioUs, /* trackIndex= */ 0)) .addOverride(new TrackSelectionOverride(trackGroupAudioUs, /* trackIndex= */ 0))
.addOverride(new TrackSelectionOverride(trackGroupAudioZh, /* trackIndex= */ 0)) .addOverride(new TrackSelectionOverride(trackGroupAudioZh, /* trackIndex= */ 0))
.setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, /* disabled= */ true) .setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, /* disabled= */ true)
@ -485,7 +490,7 @@ public class DownloadHelperTest {
@Test @Test
public void public void
getDownloadRequest_createsDownloadRequestWithConcreteTimeRange_requestContainsConcreteByteRange() getDownloadRequestForProgressive_withConcreteTimeRange_requestContainsConcreteByteRange()
throws Exception { throws Exception {
DownloadHelper downloadHelper = DownloadHelper downloadHelper =
new DownloadHelper.Factory() new DownloadHelper.Factory()
@ -504,7 +509,7 @@ public class DownloadHelperTest {
@Test @Test
public void public void
getDownloadRequest_createsDownloadRequestWithUnsetStartPosition_requestContainsConcreteByteRange() getDownloadRequestForProgressive_withUnsetStartPosition_requestContainsConcreteByteRange()
throws Exception { throws Exception {
DownloadHelper downloadHelper = DownloadHelper downloadHelper =
new DownloadHelper.Factory() new DownloadHelper.Factory()
@ -522,7 +527,8 @@ public class DownloadHelperTest {
} }
@Test @Test
public void getDownloadRequest_createsDownloadRequestWithUnsetLength_requestContainsUnsetLength() public void
getDownloadRequestForProgressive_withUnsetDuration_requestContainsUnsetByteRangeLength()
throws Exception { throws Exception {
DownloadHelper downloadHelper = DownloadHelper downloadHelper =
new DownloadHelper.Factory() new DownloadHelper.Factory()
@ -541,7 +547,7 @@ public class DownloadHelperTest {
@Test @Test
public void public void
getDownloadRequest_createsDownloadRequestForTooShortStreamWithTimeRange_requestContainsUnsetLength() getDownloadRequestForShortProgressive_withConcreteTimeRange_requestContainsUnsetByteRangeLength()
throws Exception { throws Exception {
DownloadHelper downloadHelper = DownloadHelper downloadHelper =
new DownloadHelper.Factory() new DownloadHelper.Factory()
@ -559,8 +565,7 @@ public class DownloadHelperTest {
} }
@Test @Test
public void public void getDownloadRequestForProgressive_withoutRange_requestContainsNullByteRange()
getDownloadRequest_createsDownloadRequestWithoutTimeRange_requestContainsNullByteRange()
throws Exception { throws Exception {
DownloadHelper downloadHelper = DownloadHelper downloadHelper =
new DownloadHelper.Factory() new DownloadHelper.Factory()
@ -575,17 +580,155 @@ public class DownloadHelperTest {
@Test @Test
public void public void
getDownloadRequest_createDownloadRequestWithTimeRangeForNonProgressiveStream_throwsIllegalStateException() getDownloadRequestForNonProgressive_withConcreteTimeRange_requestContainsCorrectTimeRange()
throws Exception { throws Exception {
// We use this.downloadHelper as it was created with a TestMediaSource, thus the DownloadHelper DownloadHelper downloadHelper =
// will treat it as non-progressive. new DownloadHelper(
new MediaItem.Builder()
.setUri("http://test.uri")
.setMimeType(MimeTypes.APPLICATION_M3U8)
.build(),
new TestMediaSource(),
DownloadHelper.DEFAULT_TRACK_SELECTOR_PARAMETERS,
new DefaultRendererCapabilitiesList.Factory(renderersFactory)
.createRendererCapabilitiesList());
prepareDownloadHelper(downloadHelper); prepareDownloadHelper(downloadHelper);
assertThrows( DownloadRequest downloadRequest =
IllegalStateException.class,
() ->
downloadHelper.getDownloadRequest( downloadHelper.getDownloadRequest(
/* data= */ null, /* startPositionMs= */ 0, /* durationMs= */ 10000)); /* data= */ null, /* startPositionMs= */ 0, /* durationMs= */ 10000);
assertThat(downloadRequest.timeRange).isNotNull();
assertThat(downloadRequest.timeRange.startPositionUs).isEqualTo(0);
assertThat(downloadRequest.timeRange.durationUs).isEqualTo(10000000);
}
@Test
public void
getDownloadRequestForNonProgressive_withUnsetStartPosition_requestContainsCorrectTimeRange()
throws Exception {
DownloadHelper downloadHelper =
new DownloadHelper(
new MediaItem.Builder()
.setUri("http://test.uri")
.setMimeType(MimeTypes.APPLICATION_M3U8)
.build(),
new TestMediaSource(),
DownloadHelper.DEFAULT_TRACK_SELECTOR_PARAMETERS,
new DefaultRendererCapabilitiesList.Factory(renderersFactory)
.createRendererCapabilitiesList());
prepareDownloadHelper(downloadHelper);
DownloadRequest downloadRequest =
downloadHelper.getDownloadRequest(
/* data= */ null, /* startPositionMs= */ C.TIME_UNSET, /* durationMs= */ 5000);
assertThat(downloadRequest.timeRange).isNotNull();
// The startPositionUs is set to window.defaultPositionUs.
Timeline.Window window = TEST_TIMELINE.getWindow(0, new Timeline.Window());
assertThat(downloadRequest.timeRange.startPositionUs).isEqualTo(window.defaultPositionUs);
assertThat(downloadRequest.timeRange.durationUs).isEqualTo(5000000);
}
@Test
public void
getDownloadRequestForNonProgressive_withStartPositionExceedingWindowDuration_requestContainsCorrectTimeRange()
throws Exception {
DownloadHelper downloadHelper =
new DownloadHelper(
new MediaItem.Builder()
.setUri("http://test.uri")
.setMimeType(MimeTypes.APPLICATION_M3U8)
.build(),
new TestMediaSource(),
DownloadHelper.DEFAULT_TRACK_SELECTOR_PARAMETERS,
new DefaultRendererCapabilitiesList.Factory(renderersFactory)
.createRendererCapabilitiesList());
prepareDownloadHelper(downloadHelper);
Timeline.Window window = TEST_TIMELINE.getWindow(0, new Timeline.Window());
DownloadRequest downloadRequest =
downloadHelper.getDownloadRequest(
/* data= */ null,
/* startPositionMs= */ window.durationUs + 100,
/* durationMs= */ C.TIME_UNSET);
assertThat(downloadRequest.timeRange).isNotNull();
// The startPositionUs is set to window.durationUs.
assertThat(downloadRequest.timeRange.startPositionUs).isEqualTo(window.durationUs);
assertThat(downloadRequest.timeRange.durationUs).isEqualTo(0);
}
@Test
public void
getDownloadRequestForNonProgressive_withUnsetDuration_requestContainsCorrectTimeRange()
throws Exception {
DownloadHelper downloadHelper =
new DownloadHelper(
new MediaItem.Builder()
.setUri("http://test.uri")
.setMimeType(MimeTypes.APPLICATION_M3U8)
.build(),
new TestMediaSource(),
DownloadHelper.DEFAULT_TRACK_SELECTOR_PARAMETERS,
new DefaultRendererCapabilitiesList.Factory(renderersFactory)
.createRendererCapabilitiesList());
prepareDownloadHelper(downloadHelper);
DownloadRequest downloadRequest =
downloadHelper.getDownloadRequest(
/* data= */ null, /* startPositionMs= */ 10, /* durationMs= */ C.TIME_UNSET);
assertThat(downloadRequest.timeRange).isNotNull();
assertThat(downloadRequest.timeRange.startPositionUs).isEqualTo(10_000);
Timeline.Window window = TEST_TIMELINE.getWindow(0, new Timeline.Window());
assertThat(downloadRequest.timeRange.durationUs).isEqualTo(window.durationUs - 10_000);
}
@Test
public void
getDownloadRequestForNonProgressive_withDurationExceedingWindowDuration_requestContainsCorrectTimeRange()
throws Exception {
DownloadHelper downloadHelper =
new DownloadHelper(
new MediaItem.Builder()
.setUri("http://test.uri")
.setMimeType(MimeTypes.APPLICATION_M3U8)
.build(),
new TestMediaSource(),
DownloadHelper.DEFAULT_TRACK_SELECTOR_PARAMETERS,
new DefaultRendererCapabilitiesList.Factory(renderersFactory)
.createRendererCapabilitiesList());
prepareDownloadHelper(downloadHelper);
Timeline.Window window = TEST_TIMELINE.getWindow(0, new Timeline.Window());
DownloadRequest downloadRequest =
downloadHelper.getDownloadRequest(
/* data= */ null, /* startPositionMs= */ 0, /* durationMs= */ window.durationUs + 100);
assertThat(downloadRequest.timeRange).isNotNull();
assertThat(downloadRequest.timeRange.startPositionUs).isEqualTo(0);
assertThat(downloadRequest.timeRange.durationUs).isEqualTo(window.durationUs);
}
@Test
public void getDownloadRequestForNonProgressive_withoutRange_requestContainsNullTimeRange()
throws Exception {
DownloadHelper downloadHelper =
new DownloadHelper(
new MediaItem.Builder()
.setUri("http://test.uri")
.setMimeType(MimeTypes.APPLICATION_M3U8)
.build(),
new TestMediaSource(),
DownloadHelper.DEFAULT_TRACK_SELECTOR_PARAMETERS,
new DefaultRendererCapabilitiesList.Factory(renderersFactory)
.createRendererCapabilitiesList());
prepareDownloadHelper(downloadHelper);
DownloadRequest downloadRequest = downloadHelper.getDownloadRequest(/* data= */ null);
assertThat(downloadRequest.timeRange).isNull();
} }
// https://github.com/androidx/media/issues/1224 // https://github.com/androidx/media/issues/1224

View File

@ -40,6 +40,7 @@ import static java.lang.Math.min;
import android.content.Context; import android.content.Context;
import android.net.Uri; import android.net.Uri;
import android.os.Bundle;
import android.os.Looper; import android.os.Looper;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.media3.common.AdPlaybackState; import androidx.media3.common.AdPlaybackState;
@ -94,6 +95,7 @@ import java.util.TreeMap;
* ads media sources}. These ad media source can be added to the same playlist as far as each of the * ads media sources}. These ad media source can be added to the same playlist as far as each of the
* sources have a different ads IDs. * sources have a different ads IDs.
*/ */
@SuppressWarnings("PatternMatchingInstanceof")
@UnstableApi @UnstableApi
public final class HlsInterstitialsAdsLoader implements AdsLoader { public final class HlsInterstitialsAdsLoader implements AdsLoader {
@ -205,6 +207,67 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader {
} }
} }
/**
* The state of the given ads ID to resume playback at the given {@link AdPlaybackState}.
*
* <p>This state object can be bundled and unbundled while preserving an {@link
* AdPlaybackState#adsId ads ID} of type {@link String}.
*/
public static class AdsResumptionState {
private final AdPlaybackState adPlaybackState;
/** The ads ID */
public final String adsId;
/**
* Creates a new instance.
*
* @param adsId The ads ID of the playback state.
* @param adPlaybackState The {@link AdPlaybackState} with the given {@code adsId}.
* @throws IllegalArgumentException Thrown if the passed in adsId is not equal to {@link
* AdPlaybackState#adsId}.
*/
public AdsResumptionState(String adsId, AdPlaybackState adPlaybackState) {
checkArgument(adsId.equals(adPlaybackState.adsId));
this.adsId = adsId;
this.adPlaybackState = adPlaybackState;
}
@Override
public boolean equals(@Nullable Object o) {
if (!(o instanceof AdsResumptionState)) {
return false;
}
AdsResumptionState adsResumptionState = (AdsResumptionState) o;
return Objects.equals(adsId, adsResumptionState.adsId)
&& Objects.equals(adPlaybackState, adsResumptionState.adPlaybackState);
}
@Override
public int hashCode() {
return Objects.hash(adsId, adPlaybackState);
}
private static final String FIELD_ADS_ID = Util.intToStringMaxRadix(0);
private static final String FIELD_AD_PLAYBACK_STATE = Util.intToStringMaxRadix(1);
public Bundle toBundle() {
Bundle bundle = new Bundle();
bundle.putString(FIELD_ADS_ID, adsId);
bundle.putBundle(FIELD_AD_PLAYBACK_STATE, adPlaybackState.toBundle());
return bundle;
}
public static AdsResumptionState fromBundle(Bundle bundle) {
String adsId = checkNotNull(bundle.getString(FIELD_ADS_ID));
AdPlaybackState adPlaybackState =
AdPlaybackState.fromBundle(checkNotNull(bundle.getBundle(FIELD_AD_PLAYBACK_STATE)))
.withAdsId(adsId);
return new AdsResumptionState(adsId, adPlaybackState);
}
}
/** /**
* A {@link MediaSource.Factory} to create a media source to play HLS streams with interstitials. * A {@link MediaSource.Factory} to create a media source to play HLS streams with interstitials.
*/ */
@ -474,6 +537,7 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader {
private final Map<Object, AdPlaybackState> activeAdPlaybackStates; private final Map<Object, AdPlaybackState> activeAdPlaybackStates;
private final Map<Object, Set<String>> insertedInterstitialIds; private final Map<Object, Set<String>> insertedInterstitialIds;
private final Map<Object, TreeMap<Long, AssetListData>> unresolvedAssetLists; private final Map<Object, TreeMap<Long, AssetListData>> unresolvedAssetLists;
private final Map<Object, AdPlaybackState> resumptionStates;
private final List<Listener> listeners; private final List<Listener> listeners;
private final Set<Object> unsupportedAdsIds; private final Set<Object> unsupportedAdsIds;
@ -504,6 +568,7 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader {
activeAdPlaybackStates = new HashMap<>(); activeAdPlaybackStates = new HashMap<>();
insertedInterstitialIds = new HashMap<>(); insertedInterstitialIds = new HashMap<>();
unresolvedAssetLists = new HashMap<>(); unresolvedAssetLists = new HashMap<>();
resumptionStates = new HashMap<>();
listeners = new ArrayList<>(); listeners = new ArrayList<>();
unsupportedAdsIds = new HashSet<>(); unsupportedAdsIds = new HashSet<>();
} }
@ -553,6 +618,99 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader {
throw new IllegalArgumentException(); throw new IllegalArgumentException();
} }
/**
* Returns the resumption states of the currently active {@link AdsMediaSource ads media sources}.
*
* <p>Call this method to get the resumption states before releasing the player and {@linkplain
* #addAdResumptionState(AdsResumptionState) resume at the same state later}.
*
* <p>Live streams and streams with an {@linkplain AdsMediaSource#getAdsId() ads ID} that are not
* of type string are ignored and are not included in the returned list of ad resumption state.
*
* <p>See {@link HlsInterstitialsAdsLoader.Listener#onStop(MediaItem, Object, AdPlaybackState)}
* and {@link #addAdResumptionState(Object, AdPlaybackState)} also.
*/
public ImmutableList<AdsResumptionState> getAdsResumptionStates() {
ImmutableList.Builder<AdsResumptionState> resumptionStates = new ImmutableList.Builder<>();
for (AdPlaybackState adPlaybackState : activeAdPlaybackStates.values()) {
boolean isLiveStream = adPlaybackState.endsWithLivePostrollPlaceHolder();
if (!isLiveStream && adPlaybackState.adsId instanceof String) {
resumptionStates.add(
new AdsResumptionState((String) adPlaybackState.adsId, adPlaybackState.copy()));
} else {
Log.i(
TAG,
isLiveStream
? "getAdsResumptionStates(): ignoring active ad playback state of live stream."
+ " adsId="
+ adPlaybackState.adsId
: "getAdsResumptionStates(): ignoring active ad playback state when creating"
+ " resumption states. `adsId` is not of type String: "
+ castNonNull(adPlaybackState.adsId).getClass());
}
}
return resumptionStates.build();
}
/**
* Adds the given {@link AdsResumptionState} to resume playback of the {@link AdsMediaSource} with
* {@linkplain AdsMediaSource#getAdsId() ads ID} at the provided ad playback state.
*
* <p>If added while the given ads ID is active, the resumption state is ignored. The resumption
* state for a given ads ID must be added before {@link #start(AdsMediaSource, DataSpec, Object,
* AdViewProvider, EventListener)} or after {@link #stop(AdsMediaSource, EventListener)} is called
* for that ads ID.
*
* @param adsResumptionState The state to resume with.
* @throws IllegalArgumentException Thrown if the ad playback state {@linkplain
* AdPlaybackState#endsWithLivePostrollPlaceHolder() ends with a live placeholder}.
*/
public void addAdResumptionState(AdsResumptionState adsResumptionState) {
addAdResumptionState(adsResumptionState.adsId, adsResumptionState.adPlaybackState);
}
/**
* Adds the given {@link AdPlaybackState} to resume playback of the {@link AdsMediaSource} with
* {@linkplain AdsMediaSource#getAdsId() ads ID} at the provided ad playback state.
*
* <p>If added while the given ads ID is active, the resumption state is ignored. The resumption
* state for a given ads ID must be added before {@link #start(AdsMediaSource, DataSpec, Object,
* AdViewProvider, EventListener)} or after {@link #stop(AdsMediaSource, EventListener)} is called
* for that ads ID.
*
* @param adsId The ads ID identifying the {@link AdsMediaSource} to resume with the given state.
* @param adPlaybackState The state to resume with.
* @throws IllegalArgumentException Thrown if the ad playback state {@linkplain
* AdPlaybackState#endsWithLivePostrollPlaceHolder() ends with a live placeholder}.
*/
public void addAdResumptionState(Object adsId, AdPlaybackState adPlaybackState) {
checkArgument(!adPlaybackState.endsWithLivePostrollPlaceHolder());
if (!activeAdPlaybackStates.containsKey(adsId)) {
resumptionStates.put(adsId, adPlaybackState.copy().withAdsId(adsId));
} else {
Log.w(
TAG,
"Attempting to add an ad resumption state for an adsId that is currently active. adsId="
+ adsId);
}
}
/**
* Removes the {@link AdsResumptionState} for the given ads ID, or null if there is no active ad
* playback state for the given ads ID.
*
* @param adsId The ads ID for which to remove the resumption state.
* @return The removed resumption state or null.
*/
public boolean removeAdResumptionState(Object adsId) {
return resumptionStates.remove(adsId) != null;
}
/** Clears all ad resumptions states. */
public void clearAllAdResumptionStates() {
resumptionStates.clear();
}
@Override @Override
public void start( public void start(
AdsMediaSource adsMediaSource, AdsMediaSource adsMediaSource,
@ -578,14 +736,19 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader {
activeEventListeners.put(adsId, eventListener); activeEventListeners.put(adsId, eventListener);
MediaItem mediaItem = adsMediaSource.getMediaItem(); MediaItem mediaItem = adsMediaSource.getMediaItem();
if (isHlsMediaItem(mediaItem)) { if (isHlsMediaItem(mediaItem)) {
// Mark with NONE. Update and notify later when timeline with interstitials arrives.
activeAdPlaybackStates.put(adsId, AdPlaybackState.NONE);
insertedInterstitialIds.put(adsId, new HashSet<>()); insertedInterstitialIds.put(adsId, new HashSet<>());
unresolvedAssetLists.put(adsId, new TreeMap<>()); unresolvedAssetLists.put(adsId, new TreeMap<>());
if (adsId instanceof String && resumptionStates.containsKey(adsId)) {
// Use resumption playback state. Interstitials arriving with the timeline are ignored.
putAndNotifyAdPlaybackStateUpdate(adsId, checkNotNull(resumptionStates.remove(adsId)));
} else {
// Mark with NONE and wait for the timeline to get interstitials from the HLS playlist.
activeAdPlaybackStates.put(adsId, AdPlaybackState.NONE);
}
notifyListeners(listener -> listener.onStart(mediaItem, adsId, adViewProvider)); notifyListeners(listener -> listener.onStart(mediaItem, adsId, adViewProvider));
} else { } else {
putAndNotifyAdPlaybackStateUpdate(adsId, new AdPlaybackState(adsId));
Log.w(TAG, "Unsupported media item. Playing without ads for adsId=" + adsId); Log.w(TAG, "Unsupported media item. Playing without ads for adsId=" + adsId);
putAndNotifyAdPlaybackStateUpdate(adsId, new AdPlaybackState(adsId));
unsupportedAdsIds.add(adsId); unsupportedAdsIds.add(adsId);
} }
} }
@ -714,6 +877,12 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader {
} }
} }
if (!isReleased && !unsupportedAdsIds.contains(adsId)) { if (!isReleased && !unsupportedAdsIds.contains(adsId)) {
if (adPlaybackState != null
&& adsId instanceof String
&& resumptionStates.containsKey(adsId)) {
// Update the resumption state in case the user has added one.
resumptionStates.put(adsId, adPlaybackState);
}
notifyListeners( notifyListeners(
listener -> listener ->
listener.onStop( listener.onStop(
@ -740,6 +909,7 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader {
if (activeEventListeners.isEmpty()) { if (activeEventListeners.isEmpty()) {
player = null; player = null;
} }
clearAllAdResumptionStates();
cancelPendingAssetListResolutionMessage(); cancelPendingAssetListResolutionMessage();
if (loader != null) { if (loader != null) {
loader.release(); loader.release();

View File

@ -53,6 +53,7 @@ import androidx.media3.datasource.DataSpec;
import androidx.media3.exoplayer.ExoPlaybackException; import androidx.media3.exoplayer.ExoPlaybackException;
import androidx.media3.exoplayer.ExoPlayer; import androidx.media3.exoplayer.ExoPlayer;
import androidx.media3.exoplayer.PlayerMessage; import androidx.media3.exoplayer.PlayerMessage;
import androidx.media3.exoplayer.hls.HlsInterstitialsAdsLoader.AdsResumptionState;
import androidx.media3.exoplayer.hls.HlsInterstitialsAdsLoader.Asset; import androidx.media3.exoplayer.hls.HlsInterstitialsAdsLoader.Asset;
import androidx.media3.exoplayer.hls.HlsInterstitialsAdsLoader.AssetList; import androidx.media3.exoplayer.hls.HlsInterstitialsAdsLoader.AssetList;
import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist; import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist;
@ -60,6 +61,7 @@ import androidx.media3.exoplayer.hls.playlist.HlsPlaylistParser;
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory; import androidx.media3.exoplayer.source.DefaultMediaSourceFactory;
import androidx.media3.exoplayer.source.ads.AdsLoader; import androidx.media3.exoplayer.source.ads.AdsLoader;
import androidx.media3.exoplayer.source.ads.AdsMediaSource; import androidx.media3.exoplayer.source.ads.AdsMediaSource;
import androidx.media3.test.utils.FakeMediaSource;
import androidx.media3.test.utils.FakeTimeline; import androidx.media3.test.utils.FakeTimeline;
import androidx.media3.test.utils.FakeTimeline.TimelineWindowDefinition; import androidx.media3.test.utils.FakeTimeline.TimelineWindowDefinition;
import androidx.test.core.app.ApplicationProvider; import androidx.test.core.app.ApplicationProvider;
@ -256,6 +258,270 @@ public class HlsInterstitialsAdsLoaderTest {
verifyNoMoreInteractions(mockAdsLoaderListener); verifyNoMoreInteractions(mockAdsLoaderListener);
} }
@Test
public void start_resumptionStateAvailable_resumptionStateUsedAndEventListenerCalled() {
AdPlaybackState adPlaybackState =
new AdPlaybackState("adsId", /* adGroupTimesUs...= */ 0, 10L, C.TIME_END_OF_SOURCE)
.withAdCount(/* adGroupIndex= */ 0, 1)
.withAdCount(/* adGroupIndex= */ 1, 2)
.withAdCount(/* adGroupIndex= */ 2, 3)
.withAvailableAdMediaItem(
/* adGroupIndex= */ 0,
/* adIndexInAdGroup= */ 0,
MediaItem.fromUri("http://example.com"));
adsLoader.addAdResumptionState(new AdsResumptionState("adsId", adPlaybackState));
adsLoader.setPlayer(mockPlayer);
adsLoader.start(adsMediaSource, adTagDataSpec, "adsId", mockAdViewProvider, mockEventListener);
adsLoader.stop(adsMediaSource, mockEventListener);
ArgumentCaptor<AdPlaybackState> adPlaybackStateArgumentCaptor =
ArgumentCaptor.forClass(AdPlaybackState.class);
verify(mockEventListener).onAdPlaybackState(adPlaybackStateArgumentCaptor.capture());
verify(mockAdsLoaderListener)
.onStop(any(), eq("adsId"), adPlaybackStateArgumentCaptor.capture());
assertThat(adPlaybackStateArgumentCaptor.getAllValues())
.containsExactly(adPlaybackState, adPlaybackState);
verify(mockAdsLoaderListener).onStart(eq(contentMediaItem), eq("adsId"), isNotNull());
assertThat(adsLoader.removeAdResumptionState("adsId")).isFalse();
}
@Test
public void addAdResumptionState_whileAdsIdIsActive_ignored() throws IOException {
callHandleContentTimelineChangedAndCaptureAdPlaybackState(
"#EXTM3U\n"
+ "#EXT-X-TARGETDURATION:6\n"
+ "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z\n"
+ "#EXTINF:6,\n"
+ "main1.0.ts\n"
+ "#EXTINF:6,\n"
+ "main2.0.ts\n"
+ "#EXTINF:6,\n"
+ "main3.0.ts\n"
+ "#EXT-X-ENDLIST"
+ "\n"
+ "#EXT-X-DATERANGE:"
+ "ID=\"ad0-0\","
+ "CLASS=\"com.apple.hls.interstitial\","
+ "START-DATE=\"2020-01-02T21:55:44.000Z\","
+ "CUE=\"PRE\","
+ "X-ASSET-URI=\"http://example.com/media-0-0.m3u8\""
+ "\n",
adsLoader,
/* windowIndex= */ 0,
/* windowPositionInPeriodUs= */ 0,
/* windowEndPositionInPeriodUs= */ C.TIME_END_OF_SOURCE);
adsLoader.addAdResumptionState(new AdsResumptionState("adsId", new AdPlaybackState("adsId")));
assertThat(adsLoader.getAdsResumptionStates())
.containsExactly(
new AdsResumptionState(
"adsId",
new AdPlaybackState("adsId", 0L)
.withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1)
.withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, "ad0-0")
.withAvailableAdMediaItem(
/* adGroupIndex= */ 0,
/* adIndexInAdGroup= */ 0,
new MediaItem.Builder()
.setUri("http://example.com/media-0-0.m3u8")
.setMimeType("application/x-mpegURL")
.build())));
assertThat(adsLoader.removeAdResumptionState("adsId")).isFalse();
}
@Test
public void addAdResumptionState_withLivePostRollHolder_throwsIllegalArgumentException() {
AdsResumptionState adsResumptionState =
new AdsResumptionState(
"adsId",
new AdPlaybackState("adsId")
.withLivePostrollPlaceholderAppended(/* isServerSideInserted= */ false));
assertThrows(
IllegalArgumentException.class, () -> adsLoader.addAdResumptionState(adsResumptionState));
}
@Test
public void getAdsResumptionStates_withLivePostRollPlaceholder_ignored() throws IOException {
List<AdPlaybackState> adPlaybackStates =
callHandleContentTimelineChangedForLiveAndCaptureAdPlaybackStates(
adsLoader,
/* startAdsLoader= */ true,
/* windowOffsetInFirstPeriodUs= */ 0L,
"#EXTM3U\n"
+ "#EXT-X-TARGETDURATION:6\n"
+ "#EXT-X-MEDIA-SEQUENCE:0\n"
+ "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:00:00.000Z\n"
+ "#EXTINF:6,\nmain0.0.ts\n"
+ "#EXTINF:6,\nmain1.0.ts\n"
+ "#EXTINF:6,\nmain2.0.ts\n"
+ "#EXTINF:6,\nmain3.0.ts\n"
+ "#EXTINF:6,\nmain4.0.ts\n"
+ "\n");
// active ad playback state with live post roll is ignored.
assertThat(adsLoader.getAdsResumptionStates()).isEmpty();
// Stop to verify that there was an active ad playback state when calling getAdResumptionStates.
adsLoader.stop(adsMediaSource, mockEventListener);
ArgumentCaptor<AdPlaybackState> adPlaybackState =
ArgumentCaptor.forClass(AdPlaybackState.class);
verify(mockAdsLoaderListener).onStop(any(), eq("adsId"), adPlaybackState.capture());
assertThat(adPlaybackState.getAllValues()).isEqualTo(adPlaybackStates);
}
@Test
public void getAdsResumptionStates_returnsResumptionStateOfActiveAdsIds() throws IOException {
String secondPlaylistString =
"#EXTM3U\n"
+ "#EXT-X-TARGETDURATION:6\n"
+ "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z\n"
+ "#EXTINF:6,\n"
+ "main1.0.ts\n"
+ "#EXTINF:6,\n"
+ "main2.0.ts\n"
+ "#EXTINF:6,\n"
+ "main3.0.ts\n"
+ "#EXT-X-ENDLIST"
+ "\n"
+ "#EXT-X-DATERANGE:"
+ "ID=\"ad1-1\","
+ "CLASS=\"com.apple.hls.interstitial\","
+ "START-DATE=\"2020-01-02T21:55:44.000Z\","
+ "CUE=\"POST\","
+ "X-ASSET-URI=\"http://example.com/media-1-1.m3u8\""
+ "\n";
HlsMediaPlaylist secondMediaPlaylist =
(HlsMediaPlaylist)
new HlsPlaylistParser()
.parse(
Uri.EMPTY, new ByteArrayInputStream(Util.getUtf8Bytes(secondPlaylistString)));
HlsManifest secondHlsManifest =
new HlsManifest(/* multivariantPlaylist= */ null, secondMediaPlaylist);
TimelineWindowDefinition secondInitialTimelineWindowDefinition =
new TimelineWindowDefinition.Builder()
.setPlaceholder(true)
.setDynamic(true)
.setDurationUs(C.TIME_UNSET)
.setWindowPositionInFirstPeriodUs(0)
.setMediaItem(MediaItem.fromUri("http://example.com/2.m3u8"))
.build();
AdsMediaSource secondAdsMediaSource =
new AdsMediaSource(
new FakeMediaSource(new FakeTimeline(secondInitialTimelineWindowDefinition)),
new DataSpec(secondInitialTimelineWindowDefinition.mediaItem.localConfiguration.uri),
"adsId2",
new DefaultMediaSourceFactory((Context) ApplicationProvider.getApplicationContext()),
adsLoader,
mockAdViewProvider);
AdsResumptionState firstAdsResumptionState =
new AdsResumptionState(
"adsId",
new AdPlaybackState("adsId", 0L)
.withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1)
.withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, "ad0-0")
.withAvailableAdMediaItem(
/* adGroupIndex= */ 0,
/* adIndexInAdGroup= */ 0,
new MediaItem.Builder()
.setUri("http://example.com/media-0-0.m3u8")
.setMimeType("application/x-mpegURL")
.build()));
AdsResumptionState secondAdsResumptionState =
new AdsResumptionState(
"adsId2",
new AdPlaybackState("adsId2", C.TIME_END_OF_SOURCE)
.withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1)
.withAdId(/* adGroupIndex= */ 0, /* adIndexInAdGroup= */ 0, "ad1-1")
.withAvailableAdMediaItem(
/* adGroupIndex= */ 0,
/* adIndexInAdGroup= */ 0,
new MediaItem.Builder()
.setUri("http://example.com/media-1-1.m3u8")
.setMimeType("application/x-mpegURL")
.build()));
// Start the first adsId with a pre roll.
callHandleContentTimelineChangedAndCaptureAdPlaybackState(
"#EXTM3U\n"
+ "#EXT-X-TARGETDURATION:6\n"
+ "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:55:40.000Z\n"
+ "#EXTINF:6,\n"
+ "main1.0.ts\n"
+ "#EXTINF:6,\n"
+ "main2.0.ts\n"
+ "#EXTINF:6,\n"
+ "main3.0.ts\n"
+ "#EXT-X-ENDLIST"
+ "\n"
+ "#EXT-X-DATERANGE:"
+ "ID=\"ad0-0\","
+ "CLASS=\"com.apple.hls.interstitial\","
+ "START-DATE=\"2020-01-02T21:55:44.000Z\","
+ "CUE=\"PRE\","
+ "X-ASSET-URI=\"http://example.com/media-0-0.m3u8\""
+ "\n",
adsLoader,
/* windowIndex= */ 0,
/* windowPositionInPeriodUs= */ 0,
/* windowEndPositionInPeriodUs= */ C.TIME_END_OF_SOURCE);
assertThat(adsLoader.getAdsResumptionStates()).containsExactly(firstAdsResumptionState);
// Start a second adsId with a post roll.
adsLoader.start(
secondAdsMediaSource,
new DataSpec(Uri.EMPTY),
"adsId2",
mockAdViewProvider,
mockEventListener);
adsLoader.handleContentTimelineChanged(
secondAdsMediaSource,
new FakeTimeline(
new Object[] {secondHlsManifest},
secondInitialTimelineWindowDefinition
.buildUpon()
.setDurationUs(secondMediaPlaylist.durationUs)
.setDynamic(false)
.setPlaceholder(false)
.build()));
assertThat(adsLoader.getAdsResumptionStates())
.containsExactly(firstAdsResumptionState, secondAdsResumptionState);
// Stop the first ads media source.
adsLoader.stop(adsMediaSource, mockEventListener);
assertThat(adsLoader.getAdsResumptionStates()).containsExactly(secondAdsResumptionState);
// Stop the second ads media source.
adsLoader.stop(secondAdsMediaSource, mockEventListener);
assertThat(adsLoader.getAdsResumptionStates()).isEmpty();
}
@Test
public void removeAdResumptionState_removesAvailableResumptionState() {
AdsResumptionState adsResumptionState =
new AdsResumptionState("adsId", new AdPlaybackState("adsId"));
adsLoader.addAdResumptionState(adsResumptionState);
assertThat(adsLoader.removeAdResumptionState("adsId")).isTrue();
assertThat(adsLoader.removeAdResumptionState("adsId")).isFalse();
}
@Test
public void clearAllAdResumptionStates_removesAvailableResumptionState() {
adsLoader.addAdResumptionState(new AdsResumptionState("adsId", new AdPlaybackState("adsId")));
adsLoader.addAdResumptionState("adsId2", new AdPlaybackState("adsId2"));
adsLoader.clearAllAdResumptionStates();
assertThat(adsLoader.removeAdResumptionState("adsId")).isFalse();
}
@Test @Test
public void handleContentTimelineChanged_preMidAndPostRolls_translatedToAdPlaybackState() public void handleContentTimelineChanged_preMidAndPostRolls_translatedToAdPlaybackState()
throws IOException { throws IOException {
@ -3409,6 +3675,16 @@ public class HlsInterstitialsAdsLoaderTest {
verifyNoMoreInteractions(mockPlayer); verifyNoMoreInteractions(mockPlayer);
} }
@Test
public void release_clearsResumptionStates() {
adsLoader.addAdResumptionState(
"adsId", new AdPlaybackState(/* adsId= */ "adsId", 0L, C.TIME_END_OF_SOURCE));
adsLoader.release();
assertThat(adsLoader.removeAdResumptionState("adsId")).isFalse();
}
@Test @Test
public void release_afterStartButBeforeStopped_playerListenerRemovedAfterAllSourcesStopped() { public void release_afterStartButBeforeStopped_playerListenerRemovedAfterAllSourcesStopped() {
when(mockPlayer.getCurrentTimeline()).thenReturn(new FakeTimeline(contentWindowDefinition)); when(mockPlayer.getCurrentTimeline()).thenReturn(new FakeTimeline(contentWindowDefinition));
@ -3700,6 +3976,29 @@ public class HlsInterstitialsAdsLoaderTest {
verifyNoMoreInteractions(mockEventListener); verifyNoMoreInteractions(mockEventListener);
} }
@Test
public void state_bundleUnbundleRoundTrip_createsEqualInstance() {
AdPlaybackState adPlaybackState =
new AdPlaybackState(
/* adsId= */ "1234", /* adGroupTimesUs...= */ 0L, 10L, C.TIME_END_OF_SOURCE)
.withLivePostrollPlaceholderAppended(/* isServerSideInserted= */ true)
.withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1);
AdsResumptionState adsResumptionState = new AdsResumptionState("1234", adPlaybackState);
AdsResumptionState resultingAdsResumptionState =
AdsResumptionState.fromBundle(adsResumptionState.toBundle());
assertThat(resultingAdsResumptionState).isEqualTo(adsResumptionState);
}
@Test
public void state_constructorWithAdsIdsThatDoNotMatch_throwsIllegalArgumentException() {
AdPlaybackState adPlaybackState = new AdPlaybackState("1234");
assertThrows(
IllegalArgumentException.class, () -> new AdsResumptionState("5678", adPlaybackState));
}
private List<AdPlaybackState> callHandleContentTimelineChangedForLiveAndCaptureAdPlaybackStates( private List<AdPlaybackState> callHandleContentTimelineChangedForLiveAndCaptureAdPlaybackStates(
HlsInterstitialsAdsLoader adsLoader, HlsInterstitialsAdsLoader adsLoader,
boolean startAdsLoader, boolean startAdsLoader,

View File

@ -17,6 +17,7 @@ package androidx.media3.extractor;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.media3.common.C; import androidx.media3.common.C;
import androidx.media3.common.Format;
import androidx.media3.common.ParserException; import androidx.media3.common.ParserException;
import androidx.media3.common.util.UnstableApi; import androidx.media3.common.util.UnstableApi;
import java.io.EOFException; import java.io.EOFException;
@ -122,5 +123,58 @@ public final class ExtractorUtil {
} }
} }
/**
* Returns the maximum encoded rate for samples of the given encoding.
*
* @param encoding A {@link C.Encoding}.
* @return The maximum encoded rate for this encoding in bytes per second, or {@link
* C#RATE_UNSET_INT} if unknown.
*/
public static int getMaximumEncodedRateBytesPerSecond(@C.Encoding int encoding) {
switch (encoding) {
case C.ENCODING_MP3:
return MpegAudioUtil.MAX_RATE_BYTES_PER_SECOND;
case C.ENCODING_AAC_LC:
return AacUtil.AAC_LC_MAX_RATE_BYTES_PER_SECOND;
case C.ENCODING_AAC_HE_V1:
return AacUtil.AAC_HE_V1_MAX_RATE_BYTES_PER_SECOND;
case C.ENCODING_AAC_HE_V2:
return AacUtil.AAC_HE_V2_MAX_RATE_BYTES_PER_SECOND;
case C.ENCODING_AAC_XHE:
return AacUtil.AAC_XHE_MAX_RATE_BYTES_PER_SECOND;
case C.ENCODING_AAC_ELD:
return AacUtil.AAC_ELD_MAX_RATE_BYTES_PER_SECOND;
case C.ENCODING_AC3:
return Ac3Util.AC3_MAX_RATE_BYTES_PER_SECOND;
case C.ENCODING_E_AC3:
case C.ENCODING_E_AC3_JOC:
return Ac3Util.E_AC3_MAX_RATE_BYTES_PER_SECOND;
case C.ENCODING_AC4:
return Ac4Util.MAX_RATE_BYTES_PER_SECOND;
case C.ENCODING_DTS:
return DtsUtil.DTS_MAX_RATE_BYTES_PER_SECOND;
case C.ENCODING_DTS_HD:
case C.ENCODING_DTS_UHD_P2:
return DtsUtil.DTS_HD_MAX_RATE_BYTES_PER_SECOND;
case C.ENCODING_DOLBY_TRUEHD:
return Ac3Util.TRUEHD_MAX_RATE_BYTES_PER_SECOND;
case C.ENCODING_OPUS:
return OpusUtil.MAX_BYTES_PER_SECOND;
case C.ENCODING_PCM_16BIT:
case C.ENCODING_PCM_16BIT_BIG_ENDIAN:
case C.ENCODING_PCM_24BIT:
case C.ENCODING_PCM_24BIT_BIG_ENDIAN:
case C.ENCODING_PCM_32BIT:
case C.ENCODING_PCM_32BIT_BIG_ENDIAN:
case C.ENCODING_PCM_8BIT:
case C.ENCODING_PCM_FLOAT:
case C.ENCODING_AAC_ER_BSAC:
case C.ENCODING_INVALID:
case Format.NO_VALUE:
default:
return C.RATE_UNSET_INT;
}
}
private ExtractorUtil() {} private ExtractorUtil() {}
} }