diff --git a/RELEASENOTES.md b/RELEASENOTES.md index b2d9bc05c4..55a509a420 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -40,6 +40,9 @@ * Disable use of asynchronous decryption in MediaCodec to avoid reported codec timeout issues with this platform API ([#1641](https://github.com/androidx/media/issues/1641)). + * Change `AdsMediaSource` to allow the `AdPlaybackStates` to grow by + appending ad groups. Invalid modifications are detected and throw an + exception. * Transformer: * Update parameters of `VideoFrameProcessor.registerInputStream` and `VideoFrameProcessor.Listener.onInputStreamRegistered` to use `Format`. diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ads/AdsMediaSource.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ads/AdsMediaSource.java index f9b5c279f2..a8dd7db0d7 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ads/AdsMediaSource.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/ads/AdsMediaSource.java @@ -25,6 +25,7 @@ import android.os.SystemClock; import androidx.annotation.IntDef; import androidx.annotation.Nullable; import androidx.media3.common.AdPlaybackState; +import androidx.media3.common.AdPlaybackState.AdGroup; import androidx.media3.common.AdViewProvider; import androidx.media3.common.C; import androidx.media3.common.MediaItem; @@ -54,6 +55,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; import org.checkerframework.checker.nullness.qual.MonotonicNonNull; +import org.checkerframework.checker.nullness.qual.RequiresNonNull; /** * A {@link MediaSource} that inserts ads linearly into a provided content media source. @@ -352,16 +354,61 @@ public final class AdsMediaSource extends CompositeMediaSource { private void onAdPlaybackState(AdPlaybackState adPlaybackState) { if (this.adPlaybackState == null) { - adMediaSourceHolders = new AdMediaSourceHolder[adPlaybackState.adGroupCount][]; + int playableAdGroupCount = + adPlaybackState.adGroupCount + - (adPlaybackState.endsWithLivePostrollPlaceHolder() ? 1 : 0); + adMediaSourceHolders = new AdMediaSourceHolder[playableAdGroupCount][]; Arrays.fill(adMediaSourceHolders, new AdMediaSourceHolder[0]); } else { - checkState(adPlaybackState.adGroupCount == this.adPlaybackState.adGroupCount); + int adGroupInsertionCount = + checkValidAdPlaybackStateUpdate(this.adPlaybackState, adPlaybackState); + if (adGroupInsertionCount > 0) { + adMediaSourceHolders = + growAdMediaSourceHolderGrid(adMediaSourceHolders, adGroupInsertionCount); + } } this.adPlaybackState = adPlaybackState; maybeUpdateAdMediaSources(); maybeUpdateSourceInfo(); } + private static int checkValidAdPlaybackStateUpdate( + AdPlaybackState oldAdPlaybackState, AdPlaybackState newAdPlaybackState) { + checkState( + oldAdPlaybackState.endsWithLivePostrollPlaceHolder() + == newAdPlaybackState.endsWithLivePostrollPlaceHolder()); + int insertionCount = newAdPlaybackState.adGroupCount - oldAdPlaybackState.adGroupCount; + checkState(insertionCount >= 0); + for (int i = newAdPlaybackState.removedAdGroupCount; i < oldAdPlaybackState.adGroupCount; i++) { + AdGroup oldAdGroup = oldAdPlaybackState.getAdGroup(i); + if (oldAdGroup.isLivePostrollPlaceholder()) { + // Post-roll placeholder must be at the last index. + checkState(i == oldAdPlaybackState.adGroupCount - 1); + break; + } + AdGroup newAdGroup = newAdPlaybackState.getAdGroup(i); + checkState(oldAdGroup.count <= newAdGroup.count); + checkState(oldAdGroup.timeUs == newAdGroup.timeUs); + for (int j = 0; j < oldAdGroup.count; j++) { + if (oldAdGroup.mediaItems[j] != null) { + checkState(oldAdGroup.mediaItems[j].equals(newAdGroup.mediaItems[j])); + } + } + } + return insertionCount; + } + + private static @NullableType AdMediaSourceHolder[][] growAdMediaSourceHolderGrid( + @NullableType AdMediaSourceHolder[][] grid, int insertionCount) { + @NullableType + AdMediaSourceHolder[][] grownGrid = new AdMediaSourceHolder[grid.length + insertionCount][]; + System.arraycopy(grid, 0, grownGrid, 0, grid.length); + for (int i = grid.length; i < grownGrid.length; i++) { + grownGrid[i] = new AdMediaSourceHolder[0]; + } + return grownGrid; + } + /** * Initializes any {@link AdMediaSourceHolder AdMediaSourceHolders} where the ad media URI is * newly known. @@ -378,7 +425,7 @@ public final class AdsMediaSource extends CompositeMediaSource { @Nullable AdMediaSourceHolder adMediaSourceHolder = this.adMediaSourceHolders[adGroupIndex][adIndexInAdGroup]; - AdPlaybackState.AdGroup adGroup = adPlaybackState.getAdGroup(adGroupIndex); + AdGroup adGroup = adPlaybackState.getAdGroup(adGroupIndex); if (adMediaSourceHolder != null && !adMediaSourceHolder.hasMediaSource() && adIndexInAdGroup < adGroup.mediaItems.length) { @@ -409,8 +456,12 @@ public final class AdsMediaSource extends CompositeMediaSource { } } + @RequiresNonNull("adPlaybackState") private long[][] getAdDurationsUs() { - long[][] adDurationsUs = new long[adMediaSourceHolders.length][]; + boolean hasPostRollPlaceholder = + checkNotNull(adPlaybackState).endsWithLivePostrollPlaceHolder(); + int adGroupCount = adMediaSourceHolders.length + (hasPostRollPlaceholder ? 1 : 0); + long[][] adDurationsUs = new long[adGroupCount][]; for (int i = 0; i < adMediaSourceHolders.length; i++) { adDurationsUs[i] = new long[adMediaSourceHolders[i].length]; for (int j = 0; j < adMediaSourceHolders[i].length; j++) { @@ -418,6 +469,10 @@ public final class AdsMediaSource extends CompositeMediaSource { adDurationsUs[i][j] = holder == null ? C.TIME_UNSET : holder.getDurationUs(); } } + if (hasPostRollPlaceholder) { + // Set the pseudo-durations of the placeholder that is not represented by the holders. + adDurationsUs[adGroupCount - 1] = new long[0]; + } return adDurationsUs; } diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ads/AdsMediaSourceTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ads/AdsMediaSourceTest.java index e042df6e60..ebaffda29c 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ads/AdsMediaSourceTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/ads/AdsMediaSourceTest.java @@ -17,6 +17,7 @@ package androidx.media3.exoplayer.source.ads; import static com.google.common.truth.Truth.assertThat; import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.junit.Assert.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; @@ -100,7 +101,7 @@ public final class AdsMediaSourceTest { private static final Object CONTENT_PERIOD_UID = CONTENT_TIMELINE.getUidOfPeriod(/* periodIndex= */ 0); - private static final AdPlaybackState AD_PLAYBACK_STATE = + private static final AdPlaybackState PREROLL_AD_PLAYBACK_STATE = new AdPlaybackState(/* adsId= */ new Object(), /* adGroupTimesUs...= */ 0) .withContentDurationUs(CONTENT_DURATION_US) .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) @@ -121,6 +122,7 @@ public final class AdsMediaSourceTest { private FakeMediaSource prerollAdMediaSource; @Mock private MediaSourceCaller mockMediaSourceCaller; private AdsMediaSource adsMediaSource; + private EventListener adsLoaderEventListener; @Before public void setUp() { @@ -156,15 +158,17 @@ public final class AdsMediaSourceTest { eq(TEST_ADS_ID), eq(mockAdViewProvider), eventListenerArgumentCaptor.capture()); + adsLoaderEventListener = eventListenerArgumentCaptor.getValue(); + } - // Simulate loading a preroll ad. - AdsLoader.EventListener adsLoaderEventListener = eventListenerArgumentCaptor.getValue(); - adsLoaderEventListener.onAdPlaybackState(AD_PLAYBACK_STATE); + private void setAdPlaybackState(AdPlaybackState adPlaybackState) { + adsLoaderEventListener.onAdPlaybackState(adPlaybackState); shadowOf(Looper.getMainLooper()).idle(); } @Test public void createPeriod_forPreroll_preparesChildAdMediaSourceAndRefreshesSourceInfo() { + setAdPlaybackState(PREROLL_AD_PLAYBACK_STATE); // This should be unused if we only create the preroll period. contentMediaSource.setNewSourceInfo(CONTENT_TIMELINE); adsMediaSource.createPeriod( @@ -181,12 +185,13 @@ public final class AdsMediaSourceTest { verify(mockMediaSourceCaller) .onSourceInfoRefreshed( adsMediaSource, - new SinglePeriodAdTimeline(PLACEHOLDER_CONTENT_TIMELINE, AD_PLAYBACK_STATE)); + new SinglePeriodAdTimeline(PLACEHOLDER_CONTENT_TIMELINE, PREROLL_AD_PLAYBACK_STATE)); } @Test public void createPeriod_forPreroll_preparesChildAdMediaSourceAndRefreshesSourceInfoWithAdMediaSourceInfo() { + setAdPlaybackState(PREROLL_AD_PLAYBACK_STATE); // This should be unused if we only create the preroll period. contentMediaSource.setNewSourceInfo(CONTENT_TIMELINE); adsMediaSource.createPeriod( @@ -205,11 +210,13 @@ public final class AdsMediaSourceTest { adsMediaSource, new SinglePeriodAdTimeline( PLACEHOLDER_CONTENT_TIMELINE, - AD_PLAYBACK_STATE.withAdDurationsUs(new long[][] {{PREROLL_AD_DURATION_US}}))); + PREROLL_AD_PLAYBACK_STATE.withAdDurationsUs( + new long[][] {{PREROLL_AD_DURATION_US}}))); } @Test public void createPeriod_forPreroll_createsChildPrerollAdMediaPeriod() { + setAdPlaybackState(PREROLL_AD_PLAYBACK_STATE); adsMediaSource.createPeriod( new MediaPeriodId( CONTENT_PERIOD_UID, @@ -227,6 +234,7 @@ public final class AdsMediaSourceTest { @Test public void createPeriod_forContent_createsChildContentMediaPeriodAndLoadsContentTimeline() { + setAdPlaybackState(PREROLL_AD_PLAYBACK_STATE); contentMediaSource.setNewSourceInfo(CONTENT_TIMELINE); shadowOf(Looper.getMainLooper()).idle(); adsMediaSource.createPeriod( @@ -241,11 +249,12 @@ public final class AdsMediaSourceTest { .onSourceInfoRefreshed(eq(adsMediaSource), adsTimelineCaptor.capture()); TestUtil.timelinesAreSame( adsTimelineCaptor.getValue(), - new SinglePeriodAdTimeline(CONTENT_TIMELINE, AD_PLAYBACK_STATE)); + new SinglePeriodAdTimeline(CONTENT_TIMELINE, PREROLL_AD_PLAYBACK_STATE)); } @Test public void releasePeriod_releasesChildMediaPeriodsAndSources() { + setAdPlaybackState(PREROLL_AD_PLAYBACK_STATE); contentMediaSource.setNewSourceInfo(CONTENT_TIMELINE); MediaPeriod prerollAdMediaPeriod = adsMediaSource.createPeriod( @@ -692,6 +701,239 @@ public final class AdsMediaSourceTest { .isEqualTo(133_000_000); // Overridden by AdsMediaSource with the actual source duration. } + @Test + public void onAdPlaybackState_correctAdPlaybackStateInTimeline() { + ArgumentCaptor timelineCaptor = ArgumentCaptor.forClass(Timeline.class); + + setAdPlaybackState(PREROLL_AD_PLAYBACK_STATE); + + verify(mockMediaSourceCaller).onSourceInfoRefreshed(any(), timelineCaptor.capture()); + assertThat( + timelineCaptor + .getValue() + .getPeriod(/* periodIndex= */ 0, new Timeline.Period()) + .adPlaybackState) + .isEqualTo(PREROLL_AD_PLAYBACK_STATE); + } + + @Test + public void onAdPlaybackState_growingLiveAdPlaybackState_correctAdPlaybackStateInTimeline() { + AdPlaybackState initialLiveAdPlaybackState = + new AdPlaybackState("adsId") + .withLivePostrollPlaceholderAppended(/* isServerSideInserted= */ false); + AdPlaybackState singleAdInFirstAdGroup = + initialLiveAdPlaybackState + .withNewAdGroup(/* adGroupIndex= */ 0, /* adGroupTimeUs= */ 0L) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) + .withAdDurationsUs(/* adGroupIndex= */ 0, 1_000L) + .withContentResumeOffsetUs(/* adGroupIndex= */ 0, 1_000L) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + MediaItem.fromUri("https://example.com/ad0-0")); + AdPlaybackState twoAdsInFirstAdGroup = + singleAdInFirstAdGroup + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 2) + .withAdDurationsUs(/* adGroupIndex= */ 0, 1_000L, 2_000L) + .withContentResumeOffsetUs(/* adGroupIndex= */ 0, 3_000L) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 1, + MediaItem.fromUri("https://example.com/ad0-1")); + AdPlaybackState singleAdInSecondAdGroup = + twoAdsInFirstAdGroup + .withNewAdGroup(/* adGroupIndex= */ 1, /* adGroupTimeUs= */ 10_000L) + .withAdCount(/* adGroupIndex= */ 1, /* adCount= */ 1) + .withAdDurationsUs(/* adGroupIndex= */ 1, 10_000L) + .withContentResumeOffsetUs(/* adGroupIndex= */ 1, 10_000L) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 1, + /* adIndexInAdGroup= */ 0, + MediaItem.fromUri("https://example.com/ad1-0")); + AdPlaybackState twoAdsInSecondAdGroup = + singleAdInSecondAdGroup + .withAdCount(/* adGroupIndex= */ 1, /* adCount= */ 2) + .withAdDurationsUs(/* adGroupIndex= */ 1, 10_000L, 20_000L) + .withContentResumeOffsetUs(/* adGroupIndex= */ 1, 30_000L) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 1, + /* adIndexInAdGroup= */ 1, + MediaItem.fromUri("https://example.com/ad1-1")); + ArgumentCaptor timelineCaptor = ArgumentCaptor.forClass(Timeline.class); + + setAdPlaybackState(initialLiveAdPlaybackState); + setAdPlaybackState(singleAdInFirstAdGroup); + setAdPlaybackState(twoAdsInFirstAdGroup); + setAdPlaybackState(singleAdInSecondAdGroup); + setAdPlaybackState(twoAdsInSecondAdGroup); + + verify(mockMediaSourceCaller, times(5)).onSourceInfoRefreshed(any(), timelineCaptor.capture()); + assertThat( + timelineCaptor + .getAllValues() + .get(0) + .getPeriod(0, new Timeline.Period()) + .adPlaybackState) + .isEqualTo(initialLiveAdPlaybackState); + assertThat( + timelineCaptor + .getAllValues() + .get(1) + .getPeriod(0, new Timeline.Period()) + .adPlaybackState) + .isEqualTo( + singleAdInFirstAdGroup.withAdDurationsUs( + /* adGroupIndex= */ 0, + /* adDurationsUs...= */ C.TIME_UNSET)); // durations are overridden by ads source + assertThat( + timelineCaptor + .getAllValues() + .get(2) + .getPeriod(0, new Timeline.Period()) + .adPlaybackState) + .isEqualTo( + twoAdsInFirstAdGroup.withAdDurationsUs( + /* adGroupIndex= */ 0, /* adDurationsUs...= */ C.TIME_UNSET, C.TIME_UNSET)); + assertThat( + timelineCaptor + .getAllValues() + .get(3) + .getPeriod(0, new Timeline.Period()) + .adPlaybackState) + .isEqualTo( + singleAdInSecondAdGroup + .withAdDurationsUs( + /* adGroupIndex= */ 0, /* adDurationsUs...= */ C.TIME_UNSET, C.TIME_UNSET) + .withAdDurationsUs(/* adGroupIndex= */ 1, /* adDurationsUs...= */ C.TIME_UNSET)); + assertThat( + timelineCaptor + .getAllValues() + .get(4) + .getPeriod(0, new Timeline.Period()) + .adPlaybackState) + .isEqualTo( + twoAdsInSecondAdGroup + .withAdDurationsUs( + /* adGroupIndex= */ 0, /* adDurationsUs...= */ C.TIME_UNSET, C.TIME_UNSET) + .withAdDurationsUs( + /* adGroupIndex= */ 1, /* adDurationsUs...= */ C.TIME_UNSET, C.TIME_UNSET)); + } + + @Test + public void + onAdPlaybackState_shrinkingAdPlaybackStateForLiveStream_throwsIllegalStateException() { + AdPlaybackState initialLiveAdPlaybackState = + new AdPlaybackState("adsId") + .withLivePostrollPlaceholderAppended(/* isServerSideInserted= */ false) + .withNewAdGroup(/* adGroupIndex= */ 0, /* adGroupTimeUs= */ 0L) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) + .withAdDurationsUs(/* adGroupIndex= */ 0, 1_000L) + .withContentResumeOffsetUs(/* adGroupIndex= */ 0, 1_000L) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + MediaItem.fromUri("https://example.com/ad0-0")); + setAdPlaybackState(initialLiveAdPlaybackState); + + assertThrows( + IllegalStateException.class, + () -> + setAdPlaybackState( + new AdPlaybackState("adsId") + .withLivePostrollPlaceholderAppended(/* isServerSideInserted= */ false))); + } + + @Test + public void onAdPlaybackState_timeUsOfAdGroupChanged_throwsIllegalStateException() { + AdPlaybackState initialLiveAdPlaybackState = + new AdPlaybackState("adsId") + .withLivePostrollPlaceholderAppended(/* isServerSideInserted= */ false) + .withNewAdGroup(/* adGroupIndex= */ 0, /* adGroupTimeUs= */ 0L) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) + .withAdDurationsUs(/* adGroupIndex= */ 0, 1_000L) + .withContentResumeOffsetUs(/* adGroupIndex= */ 0, 1_000L) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + MediaItem.fromUri("https://example.com/ad0-0")); + setAdPlaybackState(initialLiveAdPlaybackState); + + assertThrows( + IllegalStateException.class, + () -> + setAdPlaybackState( + initialLiveAdPlaybackState.withAdGroupTimeUs( + /* adGroupIndex= */ 0, /* adGroupTimeUs= */ 1234L))); + } + + @Test + public void onAdPlaybackState_mediaItemOfAdChanged_throwsIllegalStateException() { + AdPlaybackState initialLiveAdPlaybackState = + new AdPlaybackState("adsId") + .withLivePostrollPlaceholderAppended(/* isServerSideInserted= */ false) + .withNewAdGroup(/* adGroupIndex= */ 0, /* adGroupTimeUs= */ 0L) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) + .withAdDurationsUs(/* adGroupIndex= */ 0, 1_000L) + .withContentResumeOffsetUs(/* adGroupIndex= */ 0, 1_000L) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + MediaItem.fromUri("https://example.com/ad0-0")); + setAdPlaybackState(initialLiveAdPlaybackState); + + assertThrows( + IllegalStateException.class, + () -> + setAdPlaybackState( + initialLiveAdPlaybackState.withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + MediaItem.fromUri("https://example.com/ad0-1")))); + } + + @Test + public void onAdPlaybackState_postRollAdded_throwsIllegalStateException() { + AdPlaybackState withoutLivePostRollPlaceholder = + new AdPlaybackState("adsId") + .withNewAdGroup(/* adGroupIndex= */ 0, /* adGroupTimeUs= */ 0L) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) + .withAdDurationsUs(/* adGroupIndex= */ 0, 1_000L) + .withContentResumeOffsetUs(/* adGroupIndex= */ 0, 1_000L) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + MediaItem.fromUri("https://example.com/ad0-0")); + + setAdPlaybackState(withoutLivePostRollPlaceholder); + + assertThrows( + IllegalStateException.class, + () -> + setAdPlaybackState( + withoutLivePostRollPlaceholder.withLivePostrollPlaceholderAppended( + /* isServerSideInserted= */ false))); + } + + @Test + public void onAdPlaybackState_postRollRemoved_throwsIllegalStateException() { + AdPlaybackState withoutLivePostRollPlaceholder = + new AdPlaybackState("adsId") + .withNewAdGroup(/* adGroupIndex= */ 0, /* adGroupTimeUs= */ 0L) + .withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1) + .withAdDurationsUs(/* adGroupIndex= */ 0, 1_000L) + .withContentResumeOffsetUs(/* adGroupIndex= */ 0, 1_000L) + .withAvailableAdMediaItem( + /* adGroupIndex= */ 0, + /* adIndexInAdGroup= */ 0, + MediaItem.fromUri("https://example.com/ad0-0")); + setAdPlaybackState( + withoutLivePostRollPlaceholder.withLivePostrollPlaceholderAppended( + /* isServerSideInserted= */ false)); + + assertThrows( + IllegalStateException.class, () -> setAdPlaybackState(withoutLivePostRollPlaceholder)); + } + private static class NoOpAdsLoader implements AdsLoader { @Override