Skip to content

Commit a5107c9

Browse files
marcbaechingercopybara-github
authored andcommitted
Avoid player message trigger point in content resume offset
When an ad has a content resume offset, some positions of the primary content stream may be skipped. This change makes sure that player messages that must be triggered are not scheduled within such content positions. PiperOrigin-RevId: 828646704
1 parent ac0db86 commit a5107c9

File tree

2 files changed

+181
-6
lines changed

2 files changed

+181
-6
lines changed

libraries/exoplayer_hls/src/main/java/androidx/media3/exoplayer/hls/HlsInterstitialsAdsLoader.java

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -988,7 +988,8 @@ public boolean handleContentTimelineChanged(AdsMediaSource adsMediaSource, Timel
988988
checkNotNull(contentMediaSourceAdDataHolder.getAdPlaybackState(adsId));
989989
if (!adPlaybackState.equals(AdPlaybackState.NONE)
990990
&& !adPlaybackState.endsWithLivePostrollPlaceHolder()) {
991-
// Multiple timeline updates for VOD not supported.
991+
// Multiple VOD timeline updates not supported. Set the last published timeline and return.
992+
contentMediaSourceAdDataHolder.setLastPublishedContentTimeline(adsId, timeline);
992993
return false;
993994
}
994995

@@ -1044,6 +1045,7 @@ public boolean handleContentTimelineChanged(AdsMediaSource adsMediaSource, Timel
10441045
listener ->
10451046
listener.onContentTimelineChanged(adsMediaSource.getMediaItem(), adsId, timeline));
10461047
}
1048+
contentMediaSourceAdDataHolder.setLastPublishedContentTimeline(adsId, timeline);
10471049
return adPlaybackStateUpdated;
10481050
}
10491051

@@ -1190,7 +1192,28 @@ private void maybeExecuteOrSetNextAssetListResolutionMessage(
11901192
// Start loading immediately.
11911193
nextAssetResolution.run();
11921194
} else {
1193-
long messagePositionUs = resolutionStartTimeUs - window.positionInFirstPeriodUs;
1195+
long positionInFirstPeriodUs = window.positionInFirstPeriodUs;
1196+
Timeline referenceContentTimeline =
1197+
contentMediaSourceAdDataHolder.getLastPublishedContentTimeline(adsId);
1198+
if (referenceContentTimeline != null) {
1199+
// For live streams use the window of the timeline that is published already.
1200+
positionInFirstPeriodUs =
1201+
referenceContentTimeline.getWindow(/* windowIndex= */ 0, new Window())
1202+
.positionInFirstPeriodUs;
1203+
}
1204+
long messagePositionUs = resolutionStartTimeUs - positionInFirstPeriodUs;
1205+
AdPlaybackState adPlaybackState =
1206+
checkNotNull(contentMediaSourceAdDataHolder.getAdPlaybackState(adsId));
1207+
Period period = contentTimeline.getPeriod(/* periodIndex= */ 0, new Period());
1208+
int adGroupIndexForResolutionStartTime =
1209+
adPlaybackState.getAdGroupIndexForPositionUs(resolutionStartTimeUs, period.durationUs);
1210+
if (adGroupIndexForResolutionStartTime != C.INDEX_UNSET) {
1211+
AdGroup adGroup = adPlaybackState.getAdGroup(adGroupIndexForResolutionStartTime);
1212+
long resumePositionOfPreviousAd = adGroup.timeUs + adGroup.contentResumeOffsetUs;
1213+
// Ensure playback does not skip over the message position due to the content resume offset.
1214+
messagePositionUs =
1215+
max(messagePositionUs, resumePositionOfPreviousAd - positionInFirstPeriodUs);
1216+
}
11941217
pendingAssetListResolutionMessage =
11951218
checkNotNull(player)
11961219
.createMessage((messageType, message) -> nextAssetResolution.run())
@@ -1736,6 +1759,7 @@ private void markAdAsPlayedAndNotifyListeners(
17361759
private static final class ContentMediaSourceAdDataHolder {
17371760
private final Map<Object, EventListener> activeEventListeners;
17381761
private final Map<Object, AdPlaybackState> activeAdPlaybackStates;
1762+
private final Map<Object, Timeline> lastPublishedTimeline;
17391763
private final Map<Object, Set<String>> insertedInterstitialIds;
17401764
private final Map<Object, TreeMap<Long, AssetListData>> unresolvedAssetLists;
17411765
private final Set<Object> contentSourceAwaitingFirstAdToStart;
@@ -1745,6 +1769,7 @@ private static final class ContentMediaSourceAdDataHolder {
17451769
public ContentMediaSourceAdDataHolder() {
17461770
activeEventListeners = new HashMap<>();
17471771
activeAdPlaybackStates = new HashMap<>();
1772+
lastPublishedTimeline = new HashMap<>();
17481773
insertedInterstitialIds = new HashMap<>();
17491774
unresolvedAssetLists = new HashMap<>();
17501775
contentSourceAwaitingFirstAdToStart = new HashSet<>();
@@ -1826,6 +1851,17 @@ public Collection<AdPlaybackState> getAdPlaybackStates() {
18261851
return activeAdPlaybackStates.values();
18271852
}
18281853

1854+
/** Sets the last content timeline that was published to the player. */
1855+
public void setLastPublishedContentTimeline(Object adsId, Timeline contentTimeline) {
1856+
lastPublishedTimeline.put(adsId, contentTimeline);
1857+
}
1858+
1859+
/** Gets the last published content timeline. */
1860+
@Nullable
1861+
public Timeline getLastPublishedContentTimeline(Object adsId) {
1862+
return lastPublishedTimeline.get(adsId);
1863+
}
1864+
18291865
/** Adds an interstitial ID for the given ads ID to mark it as inserted. */
18301866
public void addInsertedInterstitialId(Object adsId, String interstitialId) {
18311867
Set<String> insertedInterstitialIdSet = insertedInterstitialIds.get(adsId);
@@ -1865,6 +1901,7 @@ public AdPlaybackState stopContentSource(Object adsId) {
18651901
insertedInterstitialIds.remove(adsId);
18661902
unresolvedAssetLists.remove(adsId);
18671903
unsupportedAdsIds.remove(adsId);
1904+
lastPublishedTimeline.remove(adsId);
18681905
contentSourceAwaitingFirstAdToStart.remove(adsId);
18691906
return activeAdPlaybackStates.remove(adsId);
18701907
}

libraries/exoplayer_hls/src/test/java/androidx/media3/exoplayer/hls/HlsInterstitialsAdsLoaderTest.java

Lines changed: 142 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2459,7 +2459,7 @@ public void handleContentTimelineChanged_preRollWithAssetList_resolveAssetListIm
24592459
+ "#EXT-X-DATERANGE:"
24602460
+ "ID=\"ad1-0\","
24612461
+ "CLASS=\"com.apple.hls.interstitial\","
2462-
+ "START-DATE=\"2020-01-02T21:00:30.000Z\","
2462+
+ "START-DATE=\"2020-01-02T21:01:21.000Z\","
24632463
+ "X-ASSET-LIST=\"http://example.com/assetlist-1-0.json\""
24642464
+ "\n";
24652465
when(mockPlayer.getContentPosition()).thenReturn(0L);
@@ -2483,7 +2483,7 @@ public void handleContentTimelineChanged_preRollWithAssetList_resolveAssetListIm
24832483
runMainLooperUntil(assetListLoadingListener::completed, TIMEOUT_MS, Clock.DEFAULT);
24842484
verify(mockAdsLoaderListener)
24852485
.onAssetListLoadCompleted(eq(contentMediaItem), eq("adsId"), eq(0), eq(0), any());
2486-
assertThat(midRollPlayerMessage.getPositionMs()).isEqualTo(3_000L);
2486+
assertThat(midRollPlayerMessage.getPositionMs()).isEqualTo(54_000L);
24872487
assertThat(midRollPlayerMessage.getPayload()).isEqualTo(contentMediaItem);
24882488
assertThat(midRollPlayerMessage.getLooper()).isEqualTo(Looper.myLooper());
24892489
InOrder inOrder = inOrder(mockPlayer);
@@ -2493,6 +2493,144 @@ public void handleContentTimelineChanged_preRollWithAssetList_resolveAssetListIm
24932493
inOrder.verifyNoMoreInteractions();
24942494
}
24952495

2496+
@Test
2497+
public void
2498+
handleContentTimelineChanged_midRollCloserToPreviousAdThan3TimesTargetDuration_schedulesNextPlayerMessageAtEndOfPreviousAdGroup()
2499+
throws IOException, TimeoutException {
2500+
String playlistString =
2501+
"#EXTM3U\n"
2502+
+ "#EXT-X-TARGETDURATION:9\n"
2503+
+ "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:00:00.000Z\n"
2504+
+ "#EXTINF:9,\n"
2505+
+ "main0.ts\n"
2506+
+ "#EXTINF:81,\n"
2507+
+ "main0.ts\n"
2508+
+ "#EXT-X-ENDLIST"
2509+
+ "\n"
2510+
+ "#EXT-X-DATERANGE:"
2511+
// The pre roll ad has a content resume offset equal to its duration of 10.123s.
2512+
// The duration is set by the asset list JSON loaded during the test.
2513+
+ "ID=\"ad0-0\","
2514+
+ "CLASS=\"com.apple.hls.interstitial\","
2515+
+ "START-DATE=\"2020-01-02T22:00:00.000Z\","
2516+
+ "CUE=\"PRE\","
2517+
+ "X-ASSET-LIST=\"http://example.com/assetlist-0-0.json\""
2518+
+ "\n"
2519+
+ "#EXT-X-DATERANGE:"
2520+
+ "ID=\"ad1-0\","
2521+
+ "CLASS=\"com.apple.hls.interstitial\","
2522+
// trigger at 00:02.000 (29 - (3 * 9)) is within content resume offset
2523+
+ "START-DATE=\"2020-01-02T21:00:29.000Z\","
2524+
+ "X-ASSET-LIST=\"http://example.com/assetlist-1-0.json\""
2525+
+ "\n";
2526+
when(mockPlayer.getContentPosition()).thenReturn(0L);
2527+
PlayerMessage midRollPlayerMessage =
2528+
new PlayerMessage(
2529+
mock(PlayerMessage.Sender.class),
2530+
mock(PlayerMessage.Target.class),
2531+
Timeline.EMPTY,
2532+
/* defaultMediaItemIndex= */ 0,
2533+
/* Clock ignored */ null,
2534+
/* Looper ignored */ null);
2535+
when(mockPlayer.createMessage(any())).thenReturn(midRollPlayerMessage);
2536+
2537+
callHandleContentTimelineChangedAndCaptureAdPlaybackState(
2538+
playlistString,
2539+
adsLoader,
2540+
/* windowIndex= */ 0,
2541+
/* windowPositionInPeriodUs= */ 0,
2542+
/* windowEndPositionInPeriodUs= */ C.TIME_END_OF_SOURCE);
2543+
2544+
runMainLooperUntil(assetListLoadingListener::completed, TIMEOUT_MS, Clock.DEFAULT);
2545+
// Assert the message is scheduled at the position right after the pre roll's resume offset.
2546+
assertThat(midRollPlayerMessage.getPositionMs()).isEqualTo(10_123L);
2547+
}
2548+
2549+
@Test
2550+
public void
2551+
handleContentTimelineChanged_liveMidRollCloserToPreviousAdThan3TimesTargetDuration_schedulesNextPlayerMessageAtEndOfPreviousAdGroup()
2552+
throws IOException, TimeoutException {
2553+
when(mockPlayer.getContentPosition()).thenReturn(0L);
2554+
PlayerMessage midRollPlayerMessage =
2555+
new PlayerMessage(
2556+
mock(PlayerMessage.Sender.class),
2557+
mock(PlayerMessage.Target.class),
2558+
Timeline.EMPTY,
2559+
/* defaultMediaItemIndex= */ 0,
2560+
/* Clock ignored */ null,
2561+
/* Looper ignored */ null);
2562+
when(mockPlayer.createMessage(any())).thenReturn(midRollPlayerMessage);
2563+
when(mockPlayer.getCurrentMediaItem()).thenReturn(contentMediaItem);
2564+
2565+
callHandleContentTimelineChangedForLiveAndCaptureAdPlaybackStates(
2566+
adsLoader,
2567+
/* startAdsLoader= */ true,
2568+
/* windowOffsetInFirstPeriodUs= */ 0L,
2569+
"#EXTM3U\n"
2570+
+ "#EXT-X-TARGETDURATION:9\n"
2571+
// window.positionInFirstPeriod = 0
2572+
+ "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:00:00.000Z\n"
2573+
+ "#EXTINF:9,\n"
2574+
+ "main0.ts\n"
2575+
+ "#EXTINF:9,\n"
2576+
+ "main0.ts\n"
2577+
+ "#EXTINF:9,\n"
2578+
+ "main0.ts\n"
2579+
+ "\n",
2580+
"#EXTM3U\n"
2581+
+ "#EXT-X-TARGETDURATION:9\n"
2582+
// window.positionInFirstPeriod = 9_000_000
2583+
+ "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:00:09.000Z\n"
2584+
+ "#EXTINF:9,\n"
2585+
+ "main0.ts\n"
2586+
+ "#EXTINF:9,\n"
2587+
+ "main0.ts"
2588+
+ "\n"
2589+
// adGroup.timeUs = 18_000_900 (window positionMs: 9_000; resumes 29_123)
2590+
+ "#EXT-X-DATERANGE:"
2591+
+ "ID=\"ad0-0\","
2592+
+ "CLASS=\"com.apple.hls.interstitial\","
2593+
+ "START-DATE=\"2020-01-02T21:00:18.000Z\","
2594+
+ "DURATION=20.123,"
2595+
+ "X-ASSET-URI=\"http://example.com/media-0-0.ts\""
2596+
+ "\n",
2597+
"#EXTM3U\n"
2598+
+ "#EXT-X-TARGETDURATION:9\n"
2599+
// window.positionInFirstPeriod = 18_000_000
2600+
+ "#EXT-X-PROGRAM-DATE-TIME:2020-01-02T21:00:18.000Z\n"
2601+
+ "#EXTINF:9,\n"
2602+
+ "main0.ts"
2603+
+ "\n"
2604+
+ "#EXT-X-DATERANGE:"
2605+
+ "ID=\"ad0-0\","
2606+
+ "CLASS=\"com.apple.hls.interstitial\","
2607+
// adGroup.timeUs = 18_000_000 (window positionMs: 0; resumes 20_123)
2608+
+ "START-DATE=\"2020-01-02T21:00:18.000Z\","
2609+
+ "DURATION=20.123,"
2610+
+ "X-ASSET-URI=\"http://example.com/media-0-0.ts\""
2611+
+ "\n"
2612+
+ "#EXTINF:81,\n"
2613+
+ "main0.ts"
2614+
+ "\n"
2615+
+ "#EXT-X-DATERANGE:"
2616+
+ "ID=\"ad1-0\","
2617+
+ "CLASS=\"com.apple.hls.interstitial\","
2618+
// adGroup.timeUs = 58_000_000 (window positionMs: 40_000)
2619+
// Message trigger at window positionMs: 40_000 - 27_000 = 13_000 which is in the resume
2620+
// offset of the previous ad.
2621+
+ "START-DATE=\"2020-01-02T21:00:58.000Z\","
2622+
+ "X-ASSET-LIST=\"http://example.com/assetlist-1-0.json\""
2623+
+ "\n");
2624+
2625+
runMainLooperUntil(
2626+
() -> midRollPlayerMessage.getPositionMs() != C.TIME_UNSET, TIMEOUT_MS, Clock.DEFAULT);
2627+
// Assert that the message is scheduled at the position right after the mid roll's resume
2628+
// offset. At the moment of scheduling, the most recent timeline/playlist has not yet been
2629+
// published. Hence the message must be scheduled at the window position of the last published
2630+
// timeline, which was created with the previous playlist.
2631+
assertThat(midRollPlayerMessage.getPositionMs()).isEqualTo(29_123L);
2632+
}
2633+
24962634
@Test
24972635
public void
24982636
handleContentTimelineChanged_startPositionAfterMidRollTimeUs_resolvesAndSchedulesMidRoll()
@@ -3501,15 +3639,15 @@ public void positionDiscontinuity_reasonSeek_immediatelyResolvesAndSchedulesNext
35013639
TIMEOUT_MS,
35023640
Clock.DEFAULT);
35033641
assertThat(midRoll1PlayerMessage.isCanceled()).isTrue();
3504-
assertThat(midRoll1PlayerMessage.getPositionMs()).isEqualTo(3_000L);
3642+
assertThat(midRoll1PlayerMessage.getPositionMs()).isEqualTo(10_123L);
35053643
assertThat(midRoll1PlayerMessage.getPayload()).isEqualTo(contentMediaItem);
35063644
assertThat(midRoll1PlayerMessage.getLooper()).isEqualTo(Looper.myLooper());
35073645
assertThat(midRoll2PlayerMessage.isCanceled()).isTrue();
35083646
assertThat(midRoll2PlayerMessage.getPositionMs()).isEqualTo(27_000L);
35093647
assertThat(midRoll2PlayerMessage.getPayload()).isEqualTo(contentMediaItem);
35103648
assertThat(midRoll1PlayerMessage.getLooper()).isEqualTo(Looper.myLooper());
35113649
assertThat(postRollPlayerMessage.isCanceled()).isFalse();
3512-
assertThat(postRollPlayerMessage.getPositionMs()).isEqualTo(63_000L);
3650+
assertThat(postRollPlayerMessage.getPositionMs()).isEqualTo(64_123L);
35133651
assertThat(postRollPlayerMessage.getPayload()).isEqualTo(contentMediaItem);
35143652
assertThat(midRoll1PlayerMessage.getLooper()).isEqualTo(Looper.myLooper());
35153653
ArgumentCaptor<AssetList> argumentCaptor = ArgumentCaptor.forClass(AssetList.class);

0 commit comments

Comments
 (0)