Compare commits

...

9 Commits

Author SHA1 Message Date
sheenachhabra
cf3faf9cff Validate gap at start when building sequence
PiperOrigin-RevId: 742710209
2025-04-01 08:31:37 -07:00
kimvde
812e078310 Make sure ExoPlayerAssetLoader's progress is not more than 100
PiperOrigin-RevId: 742710076
2025-04-01 08:29:44 -07:00
bachinger
3fddf4376c Resolve asset list and populate ad playback state
PiperOrigin-RevId: 742705857
2025-04-01 08:17:43 -07:00
tianyifeng
ce3754a740 Add PlaybackParameters.withPitch() method
Issue: androidx/media#2257
PiperOrigin-RevId: 742693410
2025-04-01 07:42:27 -07:00
sheenachhabra
209ecce6b3 Change getVideoTrackOutput() to getTrackOutput()
The updated method can be used for audio as well.

PiperOrigin-RevId: 742673561
2025-04-01 06:36:03 -07:00
bachinger
c95544156d Clip live periods that get a duration and end position
When an ad is inserted into a live period with an unset
duration, the live period needs to be wrapped with a
`ClippingMediaPeriod` and then actually be clipped to
the end position when the duration gets known. Without
this the renderers will never see an EOS which prevents
the reading/playing period from advancing.

In the case of a server side inserted ad on the other
hand, the actual clipping needs to be prevented to
keep the current behavior for SSAI streams. In an SSAI
stream, an ad inserted before the current position should
not produce a snap back to the newly inserted ad. This is
currently prevented in both places, when the updated
timeline is handled to not disable the renderers, and when
the `mediaPeriodQueue` updates the queued periods. This
behaviour is preserved to not create side effects of this
change.

PiperOrigin-RevId: 742642715
2025-04-01 04:46:31 -07:00
kimvde
f2d644b7b4 PlaybackVideoGraphWrapper: update listener logic for multi sequence
- Only have the primary sequence renderers listen to
PlaybackVideoGraphWrapper events. These events only need to be
forwarded to a single ExoPlayer instance.
- Set the DefaultVideoSinkListener only once.

PiperOrigin-RevId: 742636455
2025-04-01 04:18:39 -07:00
kimvde
595b75b7d3 Deduplicate some of the calls to DefaultVideoSink methods
Some DefaultVideoSink methods are called once per sequence, but this
doesn't make sense as the DefaultVideoSink is connected to the
VideoGraph output. This CL calls the DefaultVideoSink method only for
the primary sequence.

The other problematic DefaultVideoSink method calls will be moved in
follow-up CLs.

This is part of the effort to prepare PlaybackVideoGraphWrapper for
multi-sequence.

PiperOrigin-RevId: 742625589
2025-04-01 03:42:22 -07:00
sheenachhabra
2141d9ef9c Make forceAudioTrack flag mandatory when gap is at start
Earlier when gap is at the start of a sequence
it was automatically filled with silent audio.
Now setting forceAudioTrack flag is mandatory to
indicate that the gap at the start of a sequence
must be filled with silent audio.

PiperOrigin-RevId: 742625236
2025-04-01 03:40:41 -07:00
17 changed files with 1836 additions and 167 deletions

View File

@ -3,6 +3,9 @@
### Unreleased changes
* Common Library:
* Add `PlaybackParameters.withPitch(float)` method for easily copying a
`PlaybackParameters` with a new `pitch` value
([#2257](https://github.com/androidx/media/issues/2257)).
* ExoPlayer:
* Fix sending `CmcdData` in manifest requests for DASH, HLS, and
SmoothStreaming ([#2253](https://github.com/androidx/media/pull/2253)).
@ -10,6 +13,10 @@
error during initialization of the next media item
([#2229](https://github.com/androidx/media/issues/2229)).
* Transformer:
* Filling an initial gap (added via `addGap()`) with silent audio now
requires explicitly setting `setForceAudioTrack(true)` in
`EditedMediaItemSequence.Builder`. If the gap is in the middle of the
sequence, then this flag is not required.
* Track Selection:
* Extractors:
* MP3: Use duration and data size from unseekable Xing, VBRI and similar

View File

@ -88,6 +88,18 @@ public final class PlaybackParameters {
return new PlaybackParameters(speed, pitch);
}
/**
* Returns a copy with the given pitch.
*
* @param pitch The new pitch. Must be greater than zero.
* @return The copied playback parameters.
*/
@UnstableApi
@CheckResult
public PlaybackParameters withPitch(@FloatRange(from = 0, fromInclusive = false) float pitch) {
return new PlaybackParameters(speed, pitch);
}
@Override
public boolean equals(@Nullable Object obj) {
if (this == obj) {

View File

@ -590,10 +590,10 @@ import java.util.List;
newPeriodInfo.copyWithRequestedContentPositionUs(
oldPeriodInfo.requestedContentPositionUs);
if (!areDurationsCompatible(oldPeriodInfo.durationUs, newPeriodInfo.durationUs)) {
// The period duration changed. Remove all subsequent periods and check whether we read
// beyond the new duration.
if (oldPeriodInfo.durationUs != newPeriodInfo.durationUs) {
// The period duration changed.
periodHolder.updateClipping();
// Check whether we've read beyond the new duration.
long newDurationInRendererTime =
newPeriodInfo.durationUs == C.TIME_UNSET
? Long.MAX_VALUE
@ -607,12 +607,19 @@ import java.util.List;
periodHolder == prewarming
&& (maxRendererPrewarmingPositionUs == C.TIME_END_OF_SOURCE
|| maxRendererPrewarmingPositionUs >= newDurationInRendererTime);
// Remove all subsequent periods.
@MediaPeriodQueue.UpdatePeriodQueueResult int removeAfterResult = removeAfter(periodHolder);
if (removeAfterResult != 0) {
return removeAfterResult;
}
boolean isLivePeriodClippedForAd =
oldPeriodInfo.durationUs == C.TIME_UNSET
&& oldPeriodInfo.endPositionUs == C.TIME_END_OF_SOURCE
&& newPeriodInfo.endPositionUs != C.TIME_UNSET
&& newPeriodInfo.endPositionUs != C.TIME_END_OF_SOURCE;
int result = 0;
if (isReadingAndReadBeyondNewDuration) {
if (isReadingAndReadBeyondNewDuration
&& (oldPeriodInfo.durationUs != C.TIME_UNSET || isLivePeriodClippedForAd)) {
result |= UPDATE_PERIOD_QUEUE_ALTERED_READING_PERIOD;
}
if (isPrewarmingAndReadBeyondNewDuration) {
@ -667,7 +674,7 @@ import java.util.List;
isFollowedByTransitionToSameStream,
isLastInPeriod,
isLastInWindow,
isLastInTimeline);
/* isFinal= */ isLastInTimeline);
}
/**
@ -1225,8 +1232,6 @@ import java.util.List;
boolean isPrecededByTransitionFromSameStream) {
timeline.getPeriodByUid(periodUid, period);
int nextAdGroupIndex = period.getAdGroupIndexAfterPositionUs(startPositionUs);
boolean isNextAdGroupPostrollPlaceholder =
nextAdGroupIndex != C.INDEX_UNSET && period.isLivePostrollPlaceholder(nextAdGroupIndex);
boolean clipPeriodAtContentDuration = false;
if (nextAdGroupIndex == C.INDEX_UNSET) {
// Clip SSAI streams when at the end of the period.
@ -1248,9 +1253,13 @@ import java.util.List;
boolean isFollowedByTransitionToSameStream =
nextAdGroupIndex != C.INDEX_UNSET
&& period.isServerSideInsertedAdGroup(nextAdGroupIndex)
&& !isNextAdGroupPostrollPlaceholder;
&& !period.isLivePostrollPlaceholder(nextAdGroupIndex);
boolean isFollowedByServerSidePostRollPlaceholder =
nextAdGroupIndex != C.INDEX_UNSET
&& period.isLivePostrollPlaceholder(nextAdGroupIndex)
&& period.isServerSideInsertedAdGroup(nextAdGroupIndex);
long endPositionUs =
nextAdGroupIndex != C.INDEX_UNSET && !isNextAdGroupPostrollPlaceholder
nextAdGroupIndex != C.INDEX_UNSET && !isFollowedByServerSidePostRollPlaceholder
? period.getAdGroupTimeUs(nextAdGroupIndex)
: clipPeriodAtContentDuration ? period.durationUs : C.TIME_UNSET;
long durationUs =

View File

@ -368,7 +368,9 @@ public final class PlaybackVideoGraphWrapper implements VideoSinkProvider, Video
public VideoSink getSink(int inputIndex) {
checkState(!contains(inputVideoSinks, inputIndex));
InputVideoSink inputVideoSink = new InputVideoSink(context, inputIndex);
addListener(inputVideoSink);
if (inputIndex == PRIMARY_SEQUENCE_INDEX) {
addListener(inputVideoSink);
}
inputVideoSinks.put(inputIndex, inputVideoSink);
return inputVideoSink;
}
@ -536,6 +538,7 @@ public final class PlaybackVideoGraphWrapper implements VideoSinkProvider, Video
maybeSetOutputSurfaceInfo(surface, size.getWidth(), size.getHeight());
}
defaultVideoSink.initialize(sourceFormat);
defaultVideoSink.setListener(new DefaultVideoSinkListener(), /* executor= */ handler::post);
state = STATE_INITIALIZED;
} else {
if (!isInitialized()) {
@ -550,8 +553,6 @@ public final class PlaybackVideoGraphWrapper implements VideoSinkProvider, Video
throw new VideoSink.VideoSinkException(e, sourceFormat);
}
registeredVideoInputCount++;
defaultVideoSink.setListener(
new DefaultVideoSinkListener(), /* executor= */ checkNotNull(handler)::post);
return true;
}
@ -637,6 +638,11 @@ public final class PlaybackVideoGraphWrapper implements VideoSinkProvider, Video
defaultVideoSink.setBufferTimestampAdjustmentUs(bufferTimestampAdjustmentUs);
}
private void setChangeFrameRateStrategy(
@C.VideoChangeFrameRateStrategy int changeFrameRateStrategy) {
defaultVideoSink.setChangeFrameRateStrategy(changeFrameRateStrategy);
}
private boolean shouldRenderToInputVideoSink() {
return totalVideoInputCount != C.LENGTH_UNSET
&& totalVideoInputCount == registeredVideoInputCount;
@ -850,12 +856,16 @@ public final class PlaybackVideoGraphWrapper implements VideoSinkProvider, Video
@Override
public void setVideoFrameMetadataListener(
VideoFrameMetadataListener videoFrameMetadataListener) {
PlaybackVideoGraphWrapper.this.setVideoFrameMetadataListener(videoFrameMetadataListener);
if (inputIndex == PRIMARY_SEQUENCE_INDEX) {
PlaybackVideoGraphWrapper.this.setVideoFrameMetadataListener(videoFrameMetadataListener);
}
}
@Override
public void setPlaybackSpeed(@FloatRange(from = 0, fromInclusive = false) float speed) {
PlaybackVideoGraphWrapper.this.setPlaybackSpeed(speed);
if (inputIndex == PRIMARY_SEQUENCE_INDEX) {
PlaybackVideoGraphWrapper.this.setPlaybackSpeed(speed);
}
}
@Override
@ -892,7 +902,9 @@ public final class PlaybackVideoGraphWrapper implements VideoSinkProvider, Video
@Override
public void setChangeFrameRateStrategy(
@C.VideoChangeFrameRateStrategy int changeFrameRateStrategy) {
defaultVideoSink.setChangeFrameRateStrategy(changeFrameRateStrategy);
if (inputIndex == PRIMARY_SEQUENCE_INDEX) {
PlaybackVideoGraphWrapper.this.setChangeFrameRateStrategy(changeFrameRateStrategy);
}
}
@Override

View File

@ -10595,7 +10595,6 @@ public final class ExoPlayerTest {
player.release();
}
@SuppressWarnings("deprecation") // Checking old volume commands
@Test
public void isCommandAvailable_isTrueForAvailableCommands() {
ExoPlayer player = parameterizeTestExoPlayerBuilder(new TestExoPlayerBuilder(context)).build();
@ -14139,35 +14138,24 @@ public final class ExoPlayerTest {
.build();
// Live stream timeline with unassigned next ad group.
AdPlaybackState initialAdPlaybackState =
new AdPlaybackState(
/* adsId= */ new Object(), /* adGroupTimesUs...= */ C.TIME_END_OF_SOURCE)
.withIsServerSideInserted(/* adGroupIndex= */ 0, /* isServerSideInserted= */ true)
.withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1)
.withAdDurationsUs(new long[][] {new long[] {10 * C.MICROS_PER_SECOND}});
new AdPlaybackState(/* adsId= */ new Object())
.withLivePostrollPlaceholderAppended(/* isServerSideInserted= */ true);
// Updated timeline with ad group at 18 seconds.
long firstSampleTimeUs = TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US;
Timeline initialTimeline =
new FakeTimeline(
new TimelineWindowDefinition(
/* periodCount= */ 1,
/* id= */ 0,
/* isSeekable= */ true,
/* isDynamic= */ true,
/* durationUs= */ C.TIME_UNSET,
initialAdPlaybackState));
TimelineWindowDefinition initialTimelineWindowDefinition =
new TimelineWindowDefinition.Builder()
.setDynamic(true)
.setDurationUs(C.TIME_UNSET)
.setUid(0)
.setAdPlaybackStates(ImmutableList.of(initialAdPlaybackState))
.build();
Timeline initialTimeline = new FakeTimeline(initialTimelineWindowDefinition);
AdPlaybackState updatedAdPlaybackState =
initialAdPlaybackState.withAdGroupTimeUs(
/* adGroupIndex= */ 0,
/* adGroupTimeUs= */ firstSampleTimeUs + 18 * C.MICROS_PER_SECOND);
Timeline updatedTimeline =
new FakeTimeline(
new TimelineWindowDefinition(
/* periodCount= */ 1,
/* id= */ 0,
/* isSeekable= */ true,
/* isDynamic= */ true,
/* durationUs= */ C.TIME_UNSET,
updatedAdPlaybackState));
initialAdPlaybackState
.withNewAdGroup(0, firstSampleTimeUs + 18 * C.MICROS_PER_SECOND)
.withIsServerSideInserted(/* adGroupIndex= */ 0, /* isServerSideInserted= */ true)
.withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1)
.withAdDurationsUs(/* adGroupIndex= */ 0, new long[] {10 * C.MICROS_PER_SECOND});
// Add samples to allow player to load and start playing (but no EOS as this is a live stream).
FakeMediaSource mediaSource =
new FakeMediaSource(
@ -14181,16 +14169,26 @@ public final class ExoPlayerTest {
// Set updated ad group once we reach 20 seconds, and then continue playing until 40 seconds.
player
.createMessage((message, payload) -> mediaSource.setNewSourceInfo(updatedTimeline))
.setPosition(20_000)
.createMessage(
(message, payload) ->
mediaSource.setNewSourceInfo(
new FakeTimeline(
initialTimelineWindowDefinition
.buildUpon()
.setAdPlaybackStates(ImmutableList.of(updatedAdPlaybackState))
.build())))
.setPosition(20_000L)
.send();
player.setMediaSource(mediaSource);
player.prepare();
playUntilPosition(player, /* mediaItemIndex= */ 0, /* positionMs= */ 40_000);
playUntilPosition(player, /* mediaItemIndex= */ 0, /* positionMs= */ 40_000L);
Timeline timeline = player.getCurrentTimeline();
player.release();
// Assert that the renderer hasn't been reset despite the inserted ad group.
assertThat(videoRenderer.get().positionResetCount).isEqualTo(1);
assertThat(timeline.getPeriod(0, new Timeline.Period()).adPlaybackState.adGroupCount)
.isEqualTo(2);
}
@Test

View File

@ -27,7 +27,9 @@ import static androidx.media3.test.utils.FakeTimeline.TimelineWindowDefinition.D
import static com.google.common.truth.Truth.assertThat;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.junit.Assert.fail;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import static org.robolectric.Shadows.shadowOf;
import android.os.Looper;
@ -49,7 +51,9 @@ import androidx.media3.exoplayer.analytics.PlayerId;
import androidx.media3.exoplayer.source.MediaPeriod;
import androidx.media3.exoplayer.source.MediaSource.MediaPeriodId;
import androidx.media3.exoplayer.source.MediaSource.MediaSourceCaller;
import androidx.media3.exoplayer.source.SampleStream;
import androidx.media3.exoplayer.source.SinglePeriodTimeline;
import androidx.media3.exoplayer.source.TrackGroupArray;
import androidx.media3.exoplayer.source.ads.ServerSideAdInsertionMediaSource;
import androidx.media3.exoplayer.source.ads.SinglePeriodAdTimeline;
import androidx.media3.exoplayer.trackselection.ExoTrackSelection;
@ -488,6 +492,38 @@ public final class MediaPeriodQueueTest {
/* nextAdGroupIndex= */ C.INDEX_UNSET);
}
@Test
public void
getNextMediaPeriodInfo_singlePeriodLiveTimelineWithPostRollPlaceholder_returnsCorrectMediaPeriodInfo() {
SinglePeriodTimeline liveContentTimeline =
new SinglePeriodTimeline(
C.TIME_UNSET,
/* isSeekable= */ true,
/* isDynamic= */ true,
/* useLiveConfiguration= */ true,
/* manifest= */ null,
AD_MEDIA_ITEM);
adPlaybackState =
new AdPlaybackState(/* adsId= */ new Object())
.withLivePostrollPlaceholderAppended(/* isServerSideInserted= */ false);
SinglePeriodAdTimeline adTimeline =
new SinglePeriodAdTimeline(liveContentTimeline, adPlaybackState);
setupTimelines(adTimeline);
assertGetNextMediaPeriodInfoReturnsContentMediaPeriod(
/* periodUid= */ firstPeriodUid,
/* startPositionUs= */ 0,
/* requestedContentPositionUs= */ C.TIME_UNSET,
/* endPositionUs= */ C.TIME_END_OF_SOURCE,
/* durationUs= */ C.TIME_UNSET,
/* isPrecededByTransitionFromSameStream= */ false,
/* isFollowedByTransitionToSameStream= */ false,
/* isLastInPeriod= */ false,
/* isLastInWindow= */ false,
/* isFinal= */ false,
/* nextAdGroupIndex= */ 0);
}
@Test
@SuppressWarnings("unchecked")
public void getNextMediaPeriodInfo_multiPeriodTimelineWithNoAdsAndNoPostrollPlaceholder() {
@ -1194,6 +1230,92 @@ public final class MediaPeriodQueueTest {
assertThat(getQueueLength()).isEqualTo(3);
}
@Test
public void
updateQueuedPeriods_adInsertedIntoPeriodWithUnsetDuration_bufferedPositionEndOfSource()
throws InterruptedException, ExoPlaybackException {
// Initial setup enqueues the live period with only the placeholder ad in place.
adPlaybackState =
new AdPlaybackState(/* adsId= */ new Object())
.withLivePostrollPlaceholderAppended(/* isServerSideInserted= */ false);
SinglePeriodTimeline liveTimeline =
new SinglePeriodTimeline(
/* durationUs= */ C.TIME_UNSET,
/* isSeekable= */ true,
/* isDynamic= */ true,
/* useLiveConfiguration= */ true,
/* manifest= */ null,
AD_MEDIA_ITEM);
setupTimelines(new SinglePeriodAdTimeline(liveTimeline, adPlaybackState));
enqueueNext();
// The period needs to be prepared to get the actual buffered position from it.
mediaPeriodQueue
.getLoadingPeriod()
.mediaPeriod
.prepare(
new MediaPeriod.Callback() {
@Override
public void onPrepared(MediaPeriod mediaPeriod) {
TrackGroupArray trackGroups = mediaPeriod.getTrackGroups();
ExoTrackSelection[] selection = new ExoTrackSelection[trackGroups.length];
SampleStream[] streams = new SampleStream[trackGroups.length];
for (int i = 0; i < streams.length; i++) {
streams[i] = mock(SampleStream.class);
}
try {
when(trackSelector.selectTracks(any(), any(), any(), any()))
.thenReturn(
new TrackSelectorResult(
new RendererConfiguration[0],
selection,
Tracks.EMPTY,
/* info= */ null));
} catch (ExoPlaybackException e) {
throw new RuntimeException(e);
}
mediaPeriod.selectTracks(
selection,
new boolean[trackGroups.length],
streams,
new boolean[trackGroups.length],
/* positionUs= */ 0L);
}
@Override
public void onContinueLoadingRequested(MediaPeriod source) {}
},
0L);
// Ad inserted into timeline.
adPlaybackState =
adPlaybackState
.withNewAdGroup(/* adGroupIndex= */ 0, /* adGroupTimeUs= */ 30_000_123L)
.withAdCount(/* adGroupIndex= */ 0, /* adCount= */ 1);
updateAdTimeline(/* mediaSourceIndex= */ 0);
Timeline playlistTimeline = mediaSourceList.createTimeline();
mediaPeriodQueue
.getLoadingPeriod()
.handlePrepared(/* playbackSpeed= */ 1.0f, playlistTimeline, /* playWhenReady= */ false);
// Assume renderers have not yet read beyond the ad group timeUs.
long maxRendererReadPositionUs = Renderer.DEFAULT_DURATION_TO_PROGRESS_US + 30_000_122L;
@MediaPeriodQueue.UpdatePeriodQueueResult
int updateQueuedPeriodsResult =
mediaPeriodQueue.updateQueuedPeriods(
playlistTimeline,
/* rendererPositionUs= */ MediaPeriodQueue.INITIAL_RENDERER_POSITION_OFFSET_US,
/* maxRendererReadPositionUs= */ maxRendererReadPositionUs,
/* maxRendererPrewarmingPositionUs= */ maxRendererReadPositionUs);
assertThat(mediaPeriodQueue.getLoadingPeriod().mediaPeriod.getBufferedPositionUs())
.isEqualTo(C.TIME_END_OF_SOURCE);
assertThat(mediaPeriodQueue.getLoadingPeriod().info.durationUs).isEqualTo(30_000_123L);
assertThat(mediaPeriodQueue.getLoadingPeriod().info.startPositionUs).isEqualTo(0);
assertThat(mediaPeriodQueue.getLoadingPeriod().info.endPositionUs).isEqualTo(30_000_123L);
assertThat(mediaPeriodQueue.getLoadingPeriod().getBufferedPositionUs()).isEqualTo(30_000_123L);
assertThat(updateQueuedPeriodsResult).isEqualTo(0);
assertThat(getQueueLength()).isEqualTo(1);
}
@Test
public void
resolveMediaPeriodIdForAdsAfterPeriodPositionChange_behindAdPositionInSinglePeriodTimeline_resolvesToAd() {

View File

@ -15,18 +15,28 @@
*/
package androidx.media3.exoplayer.hls;
import static androidx.media3.common.AdPlaybackState.AD_STATE_AVAILABLE;
import static androidx.media3.common.AdPlaybackState.AD_STATE_UNAVAILABLE;
import static androidx.media3.common.Player.DISCONTINUITY_REASON_AUTO_TRANSITION;
import static androidx.media3.common.Player.DISCONTINUITY_REASON_REMOVE;
import static androidx.media3.common.Player.DISCONTINUITY_REASON_SEEK;
import static androidx.media3.common.Player.DISCONTINUITY_REASON_SEEK_ADJUSTMENT;
import static androidx.media3.common.Player.DISCONTINUITY_REASON_SKIP;
import static androidx.media3.common.Player.STATE_IDLE;
import static androidx.media3.common.util.Assertions.checkArgument;
import static androidx.media3.common.util.Assertions.checkNotNull;
import static androidx.media3.common.util.Assertions.checkState;
import static androidx.media3.common.util.Assertions.checkStateNotNull;
import static androidx.media3.common.util.Util.castNonNull;
import static androidx.media3.common.util.Util.msToUs;
import static androidx.media3.common.util.Util.usToMs;
import static androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.Interstitial.CUE_TRIGGER_POST;
import static androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.Interstitial.CUE_TRIGGER_PRE;
import static java.lang.Math.max;
import android.content.Context;
import android.net.Uri;
import android.os.Looper;
import androidx.annotation.Nullable;
import androidx.media3.common.AdPlaybackState;
import androidx.media3.common.AdViewProvider;
@ -44,8 +54,11 @@ import androidx.media3.common.util.Consumer;
import androidx.media3.common.util.Log;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.util.Util;
import androidx.media3.datasource.DataSource;
import androidx.media3.datasource.DataSpec;
import androidx.media3.datasource.DefaultDataSource;
import androidx.media3.exoplayer.ExoPlayer;
import androidx.media3.exoplayer.PlayerMessage;
import androidx.media3.exoplayer.drm.DrmSessionManagerProvider;
import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist;
import androidx.media3.exoplayer.hls.playlist.HlsMediaPlaylist.Interstitial;
@ -53,6 +66,8 @@ import androidx.media3.exoplayer.source.MediaSource;
import androidx.media3.exoplayer.source.ads.AdsLoader;
import androidx.media3.exoplayer.source.ads.AdsMediaSource;
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy;
import androidx.media3.exoplayer.upstream.Loader;
import androidx.media3.exoplayer.upstream.ParsingLoadable;
import com.google.common.collect.ImmutableList;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import java.io.IOException;
@ -63,6 +78,7 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.TreeMap;
/**
* An {@linkplain AdsLoader ads loader} that reads interstitials from the HLS playlist, adds them to
@ -448,22 +464,42 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader {
private static final String TAG = "HlsInterstitiaAdsLoader";
private final DataSource.Factory dataSourceFactory;
private final PlayerListener playerListener;
private final Map<Object, EventListener> activeEventListeners;
private final Map<Object, AdPlaybackState> activeAdPlaybackStates;
private final Map<Object, Set<String>> insertedInterstitialIds;
private final Map<Object, TreeMap<Long, AssetListData>> unresolvedAssetLists;
private final List<Listener> listeners;
private final Set<Object> unsupportedAdsIds;
@Nullable private Player player;
@Nullable private ExoPlayer player;
@Nullable private Loader loader;
private boolean isReleased;
@Nullable private PlayerMessage pendingAssetListResolutionMessage;
/** Creates an instance. */
public HlsInterstitialsAdsLoader() {
/**
* Creates an instance with a {@link DefaultDataSource.Factory} to read HLS X-ASSET-LIST JSON
* objects.
*
* @param context The context.
*/
public HlsInterstitialsAdsLoader(Context context) {
this(new DefaultDataSource.Factory(context));
}
/**
* Creates an instance.
*
* @param dataSourceFactory The data source factory to read HLS X-ASSET-LIST JSON objects.
*/
public HlsInterstitialsAdsLoader(DataSource.Factory dataSourceFactory) {
this.dataSourceFactory = dataSourceFactory;
playerListener = new PlayerListener();
activeEventListeners = new HashMap<>();
activeAdPlaybackStates = new HashMap<>();
insertedInterstitialIds = new HashMap<>();
unresolvedAssetLists = new HashMap<>();
listeners = new ArrayList<>();
unsupportedAdsIds = new HashSet<>();
}
@ -492,6 +528,7 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader {
@Override
public void setPlayer(@Nullable Player player) {
checkState(!isReleased);
checkArgument(player == null || player instanceof ExoPlayer);
if (Objects.equals(this.player, player)) {
return;
}
@ -499,7 +536,7 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader {
this.player.removeListener(playerListener);
}
checkState(player == null || activeEventListeners.isEmpty());
this.player = player;
this.player = (ExoPlayer) player;
}
@Override
@ -540,6 +577,7 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader {
// Mark with NONE. Update and notify later when timeline with interstitials arrives.
activeAdPlaybackStates.put(adsId, AdPlaybackState.NONE);
insertedInterstitialIds.put(adsId, new HashSet<>());
unresolvedAssetLists.put(adsId, new TreeMap<>());
notifyListeners(listener -> listener.onStart(mediaItem, adsId, adViewProvider));
} else {
putAndNotifyAdPlaybackStateUpdate(adsId, new AdPlaybackState(adsId));
@ -584,15 +622,41 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader {
Window window = timeline.getWindow(0, new Window());
if (window.manifest instanceof HlsManifest) {
HlsMediaPlaylist mediaPlaylist = ((HlsManifest) window.manifest).mediaPlaylist;
TreeMap<Long, AssetListData> assetListDataMap = checkNotNull(unresolvedAssetLists.get(adsId));
int unresolvedAssetListCount = assetListDataMap.size();
adPlaybackState =
window.isLive()
? mapInterstitialsForLive(
window.mediaItem,
mediaPlaylist,
adPlaybackState,
window.positionInFirstPeriodUs,
checkNotNull(insertedInterstitialIds.get(adsId)))
: mapInterstitialsForVod(
mediaPlaylist, adPlaybackState, checkNotNull(insertedInterstitialIds.get(adsId)));
window.mediaItem,
mediaPlaylist,
adPlaybackState,
checkNotNull(insertedInterstitialIds.get(adsId)));
Player player = this.player;
if (unresolvedAssetListCount != assetListDataMap.size()
&& player != null
&& Objects.equals(window.mediaItem, player.getCurrentMediaItem())) {
long contentPositionUs;
if (window.isLive()) {
int currentPublicPeriodIndex = player.getCurrentPeriodIndex();
Period publicPeriod =
player.getCurrentTimeline().getPeriod(currentPublicPeriodIndex, new Period());
// Use the default position if this is the first timeline update.
contentPositionUs =
publicPeriod.isPlaceholder
? window.defaultPositionUs
: msToUs(player.getContentPosition());
} else {
contentPositionUs = msToUs(player.getContentPosition());
}
maybeExecuteOrSetNextAssetListResolutionMessage(
adsId, timeline, /* windowIndex= */ 0, contentPositionUs);
}
}
putAndNotifyAdPlaybackStateUpdate(adsId, adPlaybackState);
if (!unsupportedAdsIds.contains(adsId)) {
@ -654,6 +718,14 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader {
}
insertedInterstitialIds.remove(adsId);
unsupportedAdsIds.remove(adsId);
unresolvedAssetLists.remove(adsId);
cancelPendingAssetListResolutionMessage();
if (pendingAssetListResolutionMessage != null
&& adsMediaSource
.getMediaItem()
.equals(castNonNull(pendingAssetListResolutionMessage).getPayload())) {
cancelPendingAssetListResolutionMessage();
}
}
@Override
@ -663,11 +735,131 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader {
if (activeEventListeners.isEmpty()) {
player = null;
}
cancelPendingAssetListResolutionMessage();
if (loader != null) {
loader.release();
loader = null;
}
isReleased = true;
}
// private methods
private void startLoadingAssetList(AssetListData assetListData) {
cancelPendingAssetListResolutionMessage();
getLoader()
.startLoading(
new ParsingLoadable<>(
dataSourceFactory.createDataSource(),
checkNotNull(assetListData.interstitial.assetListUri),
C.DATA_TYPE_AD,
new AssetListParser()),
new LoaderCallback(assetListData),
/* defaultMinRetryCount= */ 1);
notifyListeners(
(listener) ->
listener.onAssetListLoadStarted(
assetListData.mediaItem,
assetListData.adsId,
assetListData.adGroupIndex,
assetListData.adIndexInAdGroup));
}
private void maybeExecuteOrSetNextAssetListResolutionMessage(
Object adsId, Timeline contentTimeline, int windowIndex, long windowPositionUs) {
if (loader != null && loader.isLoading()) {
return;
}
cancelPendingAssetListResolutionMessage();
Window window = contentTimeline.getWindow(windowIndex, new Window());
long currentPeriodPositionUs = window.positionInFirstPeriodUs + windowPositionUs;
RunnableAtPosition nextAssetResolution = getNextAssetResolution(adsId, currentPeriodPositionUs);
if (nextAssetResolution == null) {
return;
}
long resolutionStartTimeUs =
nextAssetResolution.adStartTimeUs != Long.MAX_VALUE
? nextAssetResolution.adStartTimeUs
: window.durationUs;
// Load 2 times the target duration before the ad starts.
resolutionStartTimeUs =
max(
currentPeriodPositionUs,
resolutionStartTimeUs - (2 * nextAssetResolution.targetDurationUs));
if (resolutionStartTimeUs - currentPeriodPositionUs < 200_000L) {
// Start loading immediately.
nextAssetResolution.run();
} else {
long messagePositionUs = resolutionStartTimeUs - window.positionInFirstPeriodUs;
pendingAssetListResolutionMessage =
checkNotNull(player)
.createMessage((messageType, message) -> nextAssetResolution.run())
.setPayload(window.mediaItem)
.setLooper(checkNotNull(Looper.myLooper()))
.setPosition(usToMs(messagePositionUs));
pendingAssetListResolutionMessage.send();
}
}
@Nullable
private RunnableAtPosition getNextAssetResolution(Object adsId, long periodPositionUs) {
TreeMap<Long, AssetListData> assetListDataMap = checkNotNull(unresolvedAssetLists.get(adsId));
for (Long assetListTimeUs : assetListDataMap.keySet()) {
if (assetListDataMap.size() == 1 || periodPositionUs <= assetListTimeUs) {
AssetListData assetListData = checkNotNull(assetListDataMap.get(assetListTimeUs));
return new RunnableAtPosition(
/* adStartTimeUs= */ assetListTimeUs,
assetListData.targetDurationUs,
() -> {
if (assetListDataMap.remove(assetListTimeUs) != null) {
startLoadingAssetList(assetListData);
}
});
}
}
return null;
}
private void cancelPendingAssetListResolutionMessage() {
if (pendingAssetListResolutionMessage != null) {
pendingAssetListResolutionMessage.cancel();
pendingAssetListResolutionMessage = null;
}
}
private long getUnresolvedAssetListWindowPositionForContentPositionUs(
long contentPositionUs, Timeline timeline, int periodIndex) {
Period period = timeline.getPeriod(periodIndex, new Period());
long periodPositionUs = contentPositionUs - period.positionInWindowUs;
AdPlaybackState adPlaybackState = period.adPlaybackState;
int adGroupIndex = adPlaybackState.getAdGroupIndexForPositionUs(periodPositionUs, C.TIME_UNSET);
if (adGroupIndex != C.INDEX_UNSET) {
// Seek adjustment will snap to a playable ad behind the seek position.
AdPlaybackState.AdGroup adGroup = adPlaybackState.getAdGroup(adGroupIndex);
TreeMap<Long, AssetListData> unresolvedAssets =
unresolvedAssetLists.get(adPlaybackState.adsId);
if (unresolvedAssets != null && unresolvedAssets.containsKey(adGroup.timeUs)) {
Window window = timeline.getWindow(period.windowIndex, new Window());
return adGroup.timeUs - window.positionInFirstPeriodUs;
}
}
return C.TIME_UNSET;
}
private void notifyListeners(Consumer<Listener> callable) {
for (int i = 0; i < listeners.size(); i++) {
callable.accept(listeners.get(i));
}
}
private Loader getLoader() {
if (loader == null) {
loader = new Loader("HLS-interstitials");
}
return loader;
}
private void putAndNotifyAdPlaybackStateUpdate(Object adsId, AdPlaybackState adPlaybackState) {
@Nullable
AdPlaybackState oldAdPlaybackState = activeAdPlaybackStates.put(adsId, adPlaybackState);
@ -682,10 +874,13 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader {
}
}
private void notifyListeners(Consumer<Listener> callable) {
for (int i = 0; i < listeners.size(); i++) {
callable.accept(listeners.get(i));
private void notifyAssetResolutionFailed(Object adsId, int adGroupIndex, int adIndexInAdGroup) {
AdPlaybackState adPlaybackState = activeAdPlaybackStates.get(adsId);
if (adPlaybackState == null) {
return;
}
adPlaybackState = adPlaybackState.withAdLoadError(adGroupIndex, adIndexInAdGroup);
putAndNotifyAdPlaybackStateUpdate(adsId, adPlaybackState);
}
private static boolean isLiveMediaItem(MediaItem mediaItem, Timeline timeline) {
@ -709,7 +904,8 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader {
|| Util.inferContentType(localConfiguration.uri) == C.CONTENT_TYPE_HLS;
}
private static AdPlaybackState mapInterstitialsForLive(
private AdPlaybackState mapInterstitialsForLive(
MediaItem mediaItem,
HlsMediaPlaylist mediaPlaylist,
AdPlaybackState adPlaybackState,
long windowPositionInPeriodUs,
@ -721,8 +917,7 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader {
interstitial.cue.contains(CUE_TRIGGER_PRE)
? 0L
: (interstitial.startDateUnixUs - mediaPlaylist.startTimeUs);
if (interstitial.assetUri == null
|| insertedInterstitialIds.contains(interstitial.id)
if (insertedInterstitialIds.contains(interstitial.id)
|| interstitial.cue.contains(CUE_TRIGGER_POST)
|| positionInPlaylistWindowUs < 0) {
continue;
@ -759,13 +954,18 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader {
}
adPlaybackState =
insertOrUpdateInterstitialInAdGroup(
interstitial, /* adGroupIndex= */ insertionIndex, adPlaybackState);
mediaItem,
interstitial,
adPlaybackState,
/* adGroupIndex= */ insertionIndex,
mediaPlaylist.targetDurationUs);
insertedInterstitialIds.add(interstitial.id);
}
return adPlaybackState;
}
private static AdPlaybackState mapInterstitialsForVod(
private AdPlaybackState mapInterstitialsForVod(
MediaItem mediaItem,
HlsMediaPlaylist mediaPlaylist,
AdPlaybackState adPlaybackState,
Set<String> insertedInterstitialIds) {
@ -773,10 +973,6 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader {
ImmutableList<Interstitial> interstitials = mediaPlaylist.interstitials;
for (int i = 0; i < interstitials.size(); i++) {
Interstitial interstitial = interstitials.get(i);
if (interstitial.assetUri == null) {
Log.w(TAG, "Ignoring interstitials with X-ASSET-LIST. Not yet supported.");
continue;
}
long timeUs;
if (interstitial.cue.contains(CUE_TRIGGER_PRE)) {
timeUs = 0L;
@ -797,14 +993,23 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader {
adPlaybackState = adPlaybackState.withNewAdGroup(adGroupIndex, timeUs);
}
adPlaybackState =
insertOrUpdateInterstitialInAdGroup(interstitial, adGroupIndex, adPlaybackState);
insertOrUpdateInterstitialInAdGroup(
mediaItem,
interstitial,
adPlaybackState,
adGroupIndex,
mediaPlaylist.targetDurationUs);
insertedInterstitialIds.add(interstitial.id);
}
return adPlaybackState;
}
private static AdPlaybackState insertOrUpdateInterstitialInAdGroup(
Interstitial interstitial, int adGroupIndex, AdPlaybackState adPlaybackState) {
private AdPlaybackState insertOrUpdateInterstitialInAdGroup(
MediaItem mediaItem,
Interstitial interstitial,
AdPlaybackState adPlaybackState,
int adGroupIndex,
long playlistTargetDurationUs) {
AdPlaybackState.AdGroup adGroup = adPlaybackState.getAdGroup(adGroupIndex);
int adIndexInAdGroup = adGroup.getIndexOfAdId(interstitial.id);
if (adIndexInAdGroup != C.INDEX_UNSET) {
@ -846,6 +1051,18 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader {
.setUri(interstitial.assetUri)
.setMimeType(MimeTypes.APPLICATION_M3U8)
.build());
} else {
Object adsId = checkNotNull(adPlaybackState.adsId);
checkNotNull(unresolvedAssetLists.get(adsId))
.put(
adGroup.timeUs != C.TIME_END_OF_SOURCE ? adGroup.timeUs : Long.MAX_VALUE,
new AssetListData(
mediaItem,
adsId,
interstitial,
adGroupIndex,
adIndexInAdGroup,
playlistTargetDurationUs));
}
return adPlaybackState;
}
@ -902,24 +1119,56 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader {
}
@Override
public void onPositionDiscontinuity(
Player.PositionInfo oldPosition, Player.PositionInfo newPosition, int reason) {
if (reason != DISCONTINUITY_REASON_AUTO_TRANSITION
|| player == null
|| oldPosition.mediaItem == null
|| oldPosition.adGroupIndex == C.INDEX_UNSET) {
return;
}
player.getCurrentTimeline().getPeriod(oldPosition.periodIndex, period);
@Nullable Object adsId = period.adPlaybackState.adsId;
if (adsId != null && activeAdPlaybackStates.containsKey(adsId)) {
markAdAsPlayedAndNotifyListeners(
oldPosition.mediaItem, adsId, oldPosition.adGroupIndex, oldPosition.adIndexInAdGroup);
public void onTimelineChanged(Timeline timeline, @Player.TimelineChangeReason int reason) {
if (timeline.isEmpty()) {
cancelPendingAssetListResolutionMessage();
}
}
@Override
public void onPlaybackStateChanged(int playbackState) {
public void onPositionDiscontinuity(
Player.PositionInfo oldPosition,
Player.PositionInfo newPosition,
@Player.DiscontinuityReason int reason) {
if (player == null
|| oldPosition.mediaItem == null
|| newPosition.mediaItem == null
|| reason == DISCONTINUITY_REASON_REMOVE) {
cancelPendingAssetListResolutionMessage();
return;
}
Timeline currentTimeline = player.getCurrentTimeline();
AdPlaybackState adPlaybackState =
currentTimeline.getPeriod(newPosition.periodIndex, period).adPlaybackState;
@Nullable Object adsId = adPlaybackState.adsId;
if (adsId == null || !activeAdPlaybackStates.containsKey(adsId)) {
// Currently playing a period without ads, or an ad period not managed by this ads loader.
cancelPendingAssetListResolutionMessage();
return;
}
if ((reason == DISCONTINUITY_REASON_AUTO_TRANSITION || reason == DISCONTINUITY_REASON_SKIP)
&& oldPosition.adGroupIndex != C.INDEX_UNSET) {
currentTimeline.getPeriod(oldPosition.periodIndex, period);
markAdAsPlayedAndNotifyListeners(
oldPosition.mediaItem, adsId, oldPosition.adGroupIndex, oldPosition.adIndexInAdGroup);
} else if (reason == DISCONTINUITY_REASON_SEEK
|| reason == DISCONTINUITY_REASON_SEEK_ADJUSTMENT) {
long windowPositionUs = msToUs(newPosition.contentPositionMs);
long assetListWindowPositionUs =
getUnresolvedAssetListWindowPositionForContentPositionUs(
windowPositionUs, currentTimeline, newPosition.periodIndex);
maybeExecuteOrSetNextAssetListResolutionMessage(
adsId,
currentTimeline,
newPosition.mediaItemIndex,
assetListWindowPositionUs != C.TIME_UNSET
? assetListWindowPositionUs
: windowPositionUs);
}
}
@Override
public void onPlaybackStateChanged(@Player.State int playbackState) {
Player player = HlsInterstitialsAdsLoader.this.player;
if (playbackState != Player.STATE_ENDED || player == null || !player.isPlayingAd()) {
return;
@ -938,7 +1187,9 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader {
private void markAdAsPlayedAndNotifyListeners(
MediaItem mediaItem, Object adsId, int adGroupIndex, int adIndexInAdGroup) {
@Nullable AdPlaybackState adPlaybackState = activeAdPlaybackStates.get(adsId);
if (adPlaybackState != null) {
if (adPlaybackState != null
&& adPlaybackState.getAdGroup(adGroupIndex).states[adIndexInAdGroup]
== AD_STATE_AVAILABLE) {
adPlaybackState = adPlaybackState.withPlayedAd(adGroupIndex, adIndexInAdGroup);
putAndNotifyAdPlaybackStateUpdate(adsId, adPlaybackState);
notifyListeners(
@ -946,4 +1197,205 @@ public final class HlsInterstitialsAdsLoader implements AdsLoader {
}
}
}
private class LoaderCallback implements Loader.Callback<ParsingLoadable<AssetList>> {
private final AssetListData assetListData;
/** Creates an instance. */
public LoaderCallback(AssetListData assetListData) {
this.assetListData = assetListData;
}
@Override
public void onLoadCompleted(
ParsingLoadable<AssetList> loadable, long elapsedRealtimeMs, long loadDurationMs) {
@Nullable AssetList assetList = loadable.getResult();
AdPlaybackState adPlaybackState = activeAdPlaybackStates.get(assetListData.adsId);
if (adPlaybackState == null || assetList == null || assetList.assets.isEmpty()) {
if (adPlaybackState != null) {
handleAssetResolutionFailed(new IOException("empty asset list"), /* cancelled= */ false);
}
return;
}
AdPlaybackState.AdGroup adGroup = adPlaybackState.getAdGroup(assetListData.adGroupIndex);
long oldAdDurationUs =
adGroup.durationsUs[assetListData.adIndexInAdGroup] != C.TIME_UNSET
? adGroup.durationsUs[assetListData.adIndexInAdGroup]
: 0;
int oldAdCount = adGroup.count;
long sumOfAssetListAdDurationUs = 0L;
if (assetList.assets.size() > 1) {
// expanding to multiple ads
adPlaybackState =
adPlaybackState.withAdCount(
assetListData.adGroupIndex, oldAdCount + assetList.assets.size() - 1);
// Re-fetch ad group after ad count changed
adGroup = adPlaybackState.getAdGroup(assetListData.adGroupIndex);
}
int adIndex = assetListData.adIndexInAdGroup;
long[] newDurationsUs = adGroup.durationsUs.clone();
for (int i = 0; i < assetList.assets.size(); i++) {
Asset asset = assetList.assets.get(i);
if (i > 0) {
adIndex = oldAdCount + i - 1;
}
newDurationsUs[adIndex] = asset.durationUs;
sumOfAssetListAdDurationUs += asset.durationUs;
MediaItem mediaItem =
new MediaItem.Builder()
.setUri(asset.uri)
.setMimeType(MimeTypes.APPLICATION_M3U8)
.build();
adPlaybackState =
adPlaybackState.withAvailableAdMediaItem(
assetListData.adGroupIndex, adIndex, mediaItem);
}
adPlaybackState =
adPlaybackState.withAdDurationsUs(assetListData.adGroupIndex, newDurationsUs);
if (assetListData.interstitial.resumeOffsetUs == C.TIME_UNSET) {
adGroup = adPlaybackState.getAdGroup(assetListData.adGroupIndex);
long newContentResumeOffsetUs =
adGroup.contentResumeOffsetUs - oldAdDurationUs + sumOfAssetListAdDurationUs;
adPlaybackState =
adPlaybackState.withContentResumeOffsetUs(
assetListData.adGroupIndex, newContentResumeOffsetUs);
}
putAndNotifyAdPlaybackStateUpdate(assetListData.adsId, adPlaybackState);
notifyListeners(
listener ->
listener.onAssetListLoadCompleted(
assetListData.mediaItem,
assetListData.adsId,
assetListData.adGroupIndex,
assetListData.adIndexInAdGroup,
assetList));
maybeContinueAssetResolution();
}
@Override
public void onLoadCanceled(
ParsingLoadable<AssetList> loadable,
long elapsedRealtimeMs,
long loadDurationMs,
boolean released) {
handleAssetResolutionFailed(/* error= */ null, /* cancelled= */ true);
}
@Override
public Loader.LoadErrorAction onLoadError(
ParsingLoadable<AssetList> loadable,
long elapsedRealtimeMs,
long loadDurationMs,
IOException error,
int errorCount) {
handleAssetResolutionFailed(error, /* cancelled= */ false);
return Loader.DONT_RETRY;
}
private void handleAssetResolutionFailed(@Nullable IOException error, boolean cancelled) {
notifyAssetResolutionFailed(
assetListData.adsId, assetListData.adGroupIndex, assetListData.adIndexInAdGroup);
notifyListeners(
listener ->
listener.onAssetListLoadFailed(
assetListData.mediaItem,
assetListData.adsId,
assetListData.adGroupIndex,
assetListData.adIndexInAdGroup,
error,
cancelled));
maybeContinueAssetResolution();
}
private void maybeContinueAssetResolution() {
ExoPlayer player = HlsInterstitialsAdsLoader.this.player;
if (player == null
|| player.getPlaybackState() == STATE_IDLE
|| !assetListData.mediaItem.equals(player.getCurrentMediaItem())) {
return;
}
long contentPositionUs = msToUs(player.getContentPosition());
Timeline currentTimeline = player.getCurrentTimeline();
long assetListTimeUsForPositionUs =
getUnresolvedAssetListWindowPositionForContentPositionUs(
contentPositionUs, currentTimeline, player.getCurrentPeriodIndex());
maybeExecuteOrSetNextAssetListResolutionMessage(
assetListData.adsId,
currentTimeline,
player.getCurrentMediaItemIndex(),
/* windowPositionUs= */ assetListTimeUsForPositionUs != C.TIME_UNSET
? assetListTimeUsForPositionUs
: contentPositionUs);
}
}
private static class RunnableAtPosition implements Runnable {
public final long adStartTimeUs;
private final long targetDurationUs;
private final Runnable runnable;
/** Creates an instance. */
public RunnableAtPosition(long adStartTimeUs, long targetDurationUs, Runnable runnable) {
this.adStartTimeUs = adStartTimeUs;
this.targetDurationUs = targetDurationUs;
this.runnable = runnable;
}
@Override
public void run() {
runnable.run();
}
}
private static class AssetListData {
private final MediaItem mediaItem;
private final Object adsId;
private final int adGroupIndex;
private final int adIndexInAdGroup;
private final long targetDurationUs;
private final Interstitial interstitial;
/** Create an instance. */
public AssetListData(
MediaItem mediaItem,
Object adsId,
Interstitial interstitial,
int adGroupIndex,
int adIndexInAdGroup,
long targetDurationUs) {
checkArgument(interstitial.assetListUri != null);
this.mediaItem = mediaItem;
this.adsId = adsId;
this.adGroupIndex = adGroupIndex;
this.adIndexInAdGroup = adIndexInAdGroup;
this.targetDurationUs = targetDurationUs;
this.interstitial = interstitial;
}
@Override
public boolean equals(@Nullable Object o) {
if (!(o instanceof AssetListData)) {
return false;
}
AssetListData that = (AssetListData) o;
return adGroupIndex == that.adGroupIndex
&& adIndexInAdGroup == that.adIndexInAdGroup
&& targetDurationUs == that.targetDurationUs
&& Objects.equals(mediaItem, that.mediaItem)
&& Objects.equals(adsId, that.adsId)
&& Objects.equals(interstitial, that.interstitial);
}
@Override
public int hashCode() {
int result = mediaItem.hashCode();
result = 31 * result + adsId.hashCode();
result = 31 * result + interstitial.hashCode();
result = 31 * result + adGroupIndex;
result = 31 * result + adIndexInAdGroup;
result = (int) (31L * result + targetDurationUs);
return result;
}
}
}

View File

@ -69,6 +69,7 @@ import androidx.media3.effect.ScaleAndRotateTransformation;
import androidx.media3.effect.SingleInputVideoGraph;
import androidx.media3.exoplayer.mediacodec.MediaCodecSelector;
import androidx.media3.exoplayer.mediacodec.MediaCodecUtil;
import androidx.media3.extractor.ExtractorOutput;
import androidx.media3.muxer.MuxerException;
import androidx.media3.test.utils.BitmapPixelTestUtil;
import androidx.media3.test.utils.FakeExtractorOutput;
@ -1239,14 +1240,21 @@ public final class AndroidTestUtil {
}
/**
* Returns the {@linkplain FakeTrackOutput video track} from the {@link FakeExtractorOutput} or
* {@code null} if a video track is not found.
* Returns a {@link FakeTrackOutput} of given {@link C.TrackType} from the {@link
* FakeExtractorOutput}.
*
* @param extractorOutput The {@link ExtractorOutput} to get the {@link FakeTrackOutput} from.
* @param trackType The {@link C.TrackType}.
* @return The {@link FakeTrackOutput} or {@code null} if a track is not found.
*/
@Nullable
public static FakeTrackOutput getVideoTrackOutput(FakeExtractorOutput extractorOutput) {
public static FakeTrackOutput getTrackOutput(
FakeExtractorOutput extractorOutput, @C.TrackType int trackType) {
for (int i = 0; i < extractorOutput.numberOfTracks; i++) {
FakeTrackOutput trackOutput = extractorOutput.trackOutputs.get(i);
if (MimeTypes.isVideo(checkNotNull(trackOutput.lastFormat).sampleMimeType)) {
String sampleMimeType = checkNotNull(trackOutput.lastFormat).sampleMimeType;
if ((trackType == C.TRACK_TYPE_AUDIO && MimeTypes.isAudio(sampleMimeType))
|| (trackType == C.TRACK_TYPE_VIDEO && MimeTypes.isVideo(sampleMimeType))) {
return trackOutput;
}
}

View File

@ -47,7 +47,7 @@ import static androidx.media3.transformer.AndroidTestUtil.createFrameCountingEff
import static androidx.media3.transformer.AndroidTestUtil.createOpenGlObjects;
import static androidx.media3.transformer.AndroidTestUtil.generateTextureFromBitmap;
import static androidx.media3.transformer.AndroidTestUtil.getMuxerFactoryBasedOnApi;
import static androidx.media3.transformer.AndroidTestUtil.getVideoTrackOutput;
import static androidx.media3.transformer.AndroidTestUtil.getTrackOutput;
import static androidx.media3.transformer.AndroidTestUtil.recordTestSkipped;
import static androidx.media3.transformer.ExportResult.CONVERSION_PROCESS_NA;
import static androidx.media3.transformer.ExportResult.CONVERSION_PROCESS_TRANSCODED;
@ -1057,7 +1057,7 @@ public class TransformerEndToEndTest {
TestUtil.extractAllSamplesFromFilePath(mp4Extractor, checkNotNull(result.filePath));
assertThat(result.exportResult.fileSizeBytes).isGreaterThan(0);
List<Long> videoTimestampsUs =
checkNotNull(getVideoTrackOutput(fakeExtractorOutput)).getSampleTimesUs();
checkNotNull(getTrackOutput(fakeExtractorOutput, C.TRACK_TYPE_VIDEO)).getSampleTimesUs();
assertThat(videoTimestampsUs).hasSize(270);
assertThat(videoTimestampsUs.get(0)).isEqualTo(0);
// The second sample is originally at 1_033_333, clipping at 100_000 results in 933_333.
@ -1086,7 +1086,7 @@ public class TransformerEndToEndTest {
TestUtil.extractAllSamplesFromFilePath(mp4Extractor, checkNotNull(result.filePath));
assertThat(result.exportResult.fileSizeBytes).isGreaterThan(0);
List<Long> videoTimestampsUs =
checkNotNull(getVideoTrackOutput(fakeExtractorOutput)).getSampleTimesUs();
checkNotNull(getTrackOutput(fakeExtractorOutput, C.TRACK_TYPE_VIDEO)).getSampleTimesUs();
assertThat(videoTimestampsUs).hasSize(270);
assertThat(videoTimestampsUs.get(0)).isEqualTo(0);
// The second sample is originally at 1_033_333, clipping at 100_000 results in 933_333.
@ -1887,6 +1887,7 @@ public class TransformerEndToEndTest {
new EditedMediaItemSequence.Builder()
.addGap(100_000)
.addItem(editedMediaItem)
.setForceAudioTrack(true)
.build(),
new EditedMediaItemSequence.Builder(editedMediaItem).build())
.build();

View File

@ -17,11 +17,12 @@ package androidx.media3.transformer;
import static androidx.media3.transformer.AndroidTestUtil.MP4_ASSET;
import static androidx.media3.transformer.AndroidTestUtil.assumeFormatsSupported;
import static androidx.media3.transformer.AndroidTestUtil.getVideoTrackOutput;
import static androidx.media3.transformer.AndroidTestUtil.getTrackOutput;
import static com.google.common.truth.Truth.assertThat;
import static org.junit.Assert.assertThrows;
import android.content.Context;
import androidx.media3.common.C;
import androidx.media3.common.MediaItem;
import androidx.media3.extractor.mp4.Mp4Extractor;
import androidx.media3.extractor.text.DefaultSubtitleParserFactory;
@ -98,7 +99,7 @@ public class TransformerGapsTest {
FakeExtractorOutput fakeExtractorOutput =
TestUtil.extractAllSamplesFromFilePath(
new Mp4Extractor(new DefaultSubtitleParserFactory()), result.filePath);
FakeTrackOutput videoTrackOutput = getVideoTrackOutput(fakeExtractorOutput);
FakeTrackOutput videoTrackOutput = getTrackOutput(fakeExtractorOutput, C.TRACK_TYPE_VIDEO);
// The gap is for 1024ms with 30 fps.
int expectedBlankFrames = 31;
assertThat(videoTrackOutput.getSampleCount())
@ -127,32 +128,13 @@ public class TransformerGapsTest {
FakeExtractorOutput fakeExtractorOutput =
TestUtil.extractAllSamplesFromFilePath(
new Mp4Extractor(new DefaultSubtitleParserFactory()), result.filePath);
FakeTrackOutput videoTrackOutput = getVideoTrackOutput(fakeExtractorOutput);
FakeTrackOutput videoTrackOutput = getTrackOutput(fakeExtractorOutput, C.TRACK_TYPE_VIDEO);
// The gap is for 1024ms with 30 fps.
int expectedBlankFrames = 31;
assertThat(videoTrackOutput.getSampleCount())
.isEqualTo(2 * MP4_ASSET.videoFrameCount + expectedBlankFrames);
}
// TODO: b/391111085 - Change test when gaps at the start of the sequence are supported.
@Test
public void export_withTwoVideoOnlyMediaItemsAndGapAtStart_throws() {
Transformer transformer = new Transformer.Builder(context).build();
Composition composition =
new Composition.Builder(
new EditedMediaItemSequence.Builder()
.addGap(/* durationUs= */ 1_000_000)
.addItem(VIDEO_ONLY_MEDIA_ITEM)
.addItem(VIDEO_ONLY_MEDIA_ITEM)
.build())
.build();
TransformerAndroidTestRunner transformerAndroidTestRunner =
new TransformerAndroidTestRunner.Builder(context, transformer).build();
assertThrows(
ExportException.class, () -> transformerAndroidTestRunner.run(testId, composition));
}
@Test
public void export_withTwoVideoOnlyMediaItemsAndGapInMiddle_insertsBlankFramesForGap()
throws Exception {
@ -179,7 +161,7 @@ public class TransformerGapsTest {
FakeExtractorOutput fakeExtractorOutput =
TestUtil.extractAllSamplesFromFilePath(
new Mp4Extractor(new DefaultSubtitleParserFactory()), result.filePath);
FakeTrackOutput videoTrackOutput = getVideoTrackOutput(fakeExtractorOutput);
FakeTrackOutput videoTrackOutput = getTrackOutput(fakeExtractorOutput, C.TRACK_TYPE_VIDEO);
// The gap is for 1 sec with 30 fps.
int expectedBlankFrames = 30;
assertThat(videoTrackOutput.getSampleCount())
@ -212,30 +194,22 @@ public class TransformerGapsTest {
FakeExtractorOutput fakeExtractorOutput =
TestUtil.extractAllSamplesFromFilePath(
new Mp4Extractor(new DefaultSubtitleParserFactory()), result.filePath);
FakeTrackOutput videoTrackOutput = getVideoTrackOutput(fakeExtractorOutput);
FakeTrackOutput videoTrackOutput = getTrackOutput(fakeExtractorOutput, C.TRACK_TYPE_VIDEO);
// The gap is for 1 sec with 30 fps.
int expectedBlankFrames = 30;
assertThat(videoTrackOutput.getSampleCount())
.isEqualTo(2 * MP4_ASSET.videoFrameCount + expectedBlankFrames);
}
// TODO: b/391111085 - Change test when gaps at the start of the sequence are supported.
@Test
public void export_withTwoMediaItemsAndGapAtStart_throws() {
Transformer transformer = new Transformer.Builder(context).build();
Composition composition =
new Composition.Builder(
new EditedMediaItemSequence.Builder()
.addGap(/* durationUs= */ 1_000_000)
.addItem(AUDIO_VIDEO_MEDIA_ITEM)
.addItem(AUDIO_VIDEO_MEDIA_ITEM)
.build())
.build();
TransformerAndroidTestRunner transformerAndroidTestRunner =
new TransformerAndroidTestRunner.Builder(context, transformer).build();
public void buildSequence_withTwoMediaItemsAndGapAtStart_throws() {
EditedMediaItemSequence.Builder sequenceBuilder =
new EditedMediaItemSequence.Builder()
.addGap(/* durationUs= */ 1_000_000)
.addItem(AUDIO_VIDEO_MEDIA_ITEM)
.addItem(AUDIO_VIDEO_MEDIA_ITEM);
assertThrows(
ExportException.class, () -> transformerAndroidTestRunner.run(testId, composition));
assertThrows(IllegalArgumentException.class, sequenceBuilder::build);
}
@Test
@ -263,7 +237,7 @@ public class TransformerGapsTest {
FakeExtractorOutput fakeExtractorOutput =
TestUtil.extractAllSamplesFromFilePath(
new Mp4Extractor(new DefaultSubtitleParserFactory()), result.filePath);
FakeTrackOutput videoTrackOutput = getVideoTrackOutput(fakeExtractorOutput);
FakeTrackOutput videoTrackOutput = getTrackOutput(fakeExtractorOutput, C.TRACK_TYPE_VIDEO);
// The gap is for 1 sec with 30 fps.
int expectedBlankFrames = 30;
assertThat(videoTrackOutput.getSampleCount())
@ -295,7 +269,7 @@ public class TransformerGapsTest {
FakeExtractorOutput fakeExtractorOutput =
TestUtil.extractAllSamplesFromFilePath(
new Mp4Extractor(new DefaultSubtitleParserFactory()), result.filePath);
FakeTrackOutput videoTrackOutput = getVideoTrackOutput(fakeExtractorOutput);
FakeTrackOutput videoTrackOutput = getTrackOutput(fakeExtractorOutput, C.TRACK_TYPE_VIDEO);
// The gap is for 1 sec with 30 fps.
int expectedBlankFrames = 30;
assertThat(videoTrackOutput.getSampleCount())
@ -328,7 +302,7 @@ public class TransformerGapsTest {
FakeExtractorOutput fakeExtractorOutput =
TestUtil.extractAllSamplesFromFilePath(
new Mp4Extractor(new DefaultSubtitleParserFactory()), result.filePath);
FakeTrackOutput videoTrackOutput = getVideoTrackOutput(fakeExtractorOutput);
FakeTrackOutput videoTrackOutput = getTrackOutput(fakeExtractorOutput, C.TRACK_TYPE_VIDEO);
// The gap is for 1024ms with 30 fps.
int expectedBlankFramesForAudioOnlyItem = 31;
// The gap is for 1 sec with 30 fps.

View File

@ -98,6 +98,9 @@ public final class EditedMediaItemSequence {
*
* <p>A gap is a period of time with no media.
*
* <p>If the gap is at the start of the sequence then {@linkplain #setForceAudioTrack(boolean)
* force audio track} flag must be set to force silent audio.
*
* <p>Gaps at the start of the sequence are not supported if the sequence has video.
*
* @param durationUs The duration of the gap, in milliseconds.
@ -230,6 +233,9 @@ public final class EditedMediaItemSequence {
this.editedMediaItems = builder.items.build();
checkArgument(
!editedMediaItems.isEmpty(), "The sequence must contain at least one EditedMediaItem.");
checkArgument(
!editedMediaItems.get(0).isGap() || builder.forceAudioTrack,
"If the first item in the sequence is a Gap, then forceAudioTrack flag must be set");
this.isLooping = builder.isLooping;
this.forceAudioTrack = builder.forceAudioTrack;
}

View File

@ -272,8 +272,10 @@ public final class ExoPlayerAssetLoader implements AssetLoader {
public @Transformer.ProgressState int getProgress(ProgressHolder progressHolder) {
if (progressState == PROGRESS_STATE_AVAILABLE) {
long durationMs = player.getDuration();
long positionMs = player.getCurrentPosition();
progressHolder.progress = min((int) (positionMs * 100 / durationMs), 99);
// The player position can become greater than the duration. This happens if the player is
// using a StandaloneMediaClock because the renderers have ended.
long positionMs = min(player.getCurrentPosition(), durationMs);
progressHolder.progress = (int) (positionMs * 100 / durationMs);
}
return progressState;
}

View File

@ -137,7 +137,7 @@ import org.checkerframework.checker.nullness.qual.MonotonicNonNull;
Looper looper) {
editedMediaItems = sequence.editedMediaItems;
isLooping = sequence.isLooping;
this.forceAudioTrack = sequence.forceAudioTrack || sequence.editedMediaItems.get(0).isGap();
this.forceAudioTrack = sequence.forceAudioTrack;
this.assetLoaderFactory = new GapInterceptingAssetLoaderFactory(assetLoaderFactory);
this.compositionSettings = compositionSettings;
sequenceAssetLoaderListener = listener;

View File

@ -481,7 +481,10 @@ public class CompositionExportTest {
new EditedMediaItem.Builder(MediaItem.fromUri(ASSET_URI_PREFIX + FILE_AUDIO_RAW)).build();
Composition composition =
new Composition.Builder(
new EditedMediaItemSequence.Builder().addGap(1_000_000).build(),
new EditedMediaItemSequence.Builder()
.addGap(1_000_000)
.setForceAudioTrack(true)
.build(),
new EditedMediaItemSequence.Builder(audioItem1000ms).build())
.build();
@ -512,6 +515,7 @@ public class CompositionExportTest {
new EditedMediaItemSequence.Builder()
.addGap(100_000)
.addItem(audioEditedMediaItem)
.setForceAudioTrack(true)
.build(),
new EditedMediaItemSequence.Builder(otherAudioEditedMediaItem).build())
.build();
@ -591,6 +595,7 @@ public class CompositionExportTest {
new EditedMediaItemSequence.Builder()
.addGap(200_000)
.addItem(audioEditedMediaItem)
.setForceAudioTrack(true)
.build())
.setTransmuxVideo(true)
.build();
@ -716,7 +721,10 @@ public class CompositionExportTest {
Composition composition =
new Composition.Builder(
new EditedMediaItemSequence.Builder(audioItem1000ms).build(),
new EditedMediaItemSequence.Builder().addGap(1_000_000).build())
new EditedMediaItemSequence.Builder()
.addGap(1_000_000)
.setForceAudioTrack(true)
.build())
.build();
transformer.start(composition, outputDir.newFile().getPath());
@ -733,8 +741,14 @@ public class CompositionExportTest {
new TestTransformerBuilder(context).setMuxerFactory(muxerFactory).build();
Composition composition =
new Composition.Builder(
new EditedMediaItemSequence.Builder().addGap(500_000).build(),
new EditedMediaItemSequence.Builder().addGap(500_000).build())
new EditedMediaItemSequence.Builder()
.addGap(500_000)
.setForceAudioTrack(true)
.build(),
new EditedMediaItemSequence.Builder()
.addGap(500_000)
.setForceAudioTrack(true)
.build())
.build();
transformer.start(composition, outputDir.newFile().getPath());

View File

@ -148,7 +148,7 @@ public final class MediaItemExportTest {
new TestTransformerBuilder(context).setMuxerFactory(muxerFactory).build();
EditedMediaItemSequence gapSequence =
new EditedMediaItemSequence.Builder().addGap(500_000).build();
new EditedMediaItemSequence.Builder().addGap(500_000).setForceAudioTrack(true).build();
transformer.start(new Composition.Builder(gapSequence).build(), outputDir.newFile().getPath());
ExportResult result = TransformerTestRunner.runLooper(transformer);

View File

@ -551,7 +551,11 @@ public final class SequenceExportTest {
new EditedMediaItem.Builder(MediaItem.fromUri(ASSET_URI_PREFIX + FILE_AUDIO_RAW_VIDEO))
.build();
EditedMediaItemSequence sequence =
new EditedMediaItemSequence.Builder().addGap(500_000).addItem(audioVideoItem).build();
new EditedMediaItemSequence.Builder()
.addGap(500_000)
.addItem(audioVideoItem)
.setForceAudioTrack(true)
.build();
Composition composition = new Composition.Builder(sequence).build();
transformer.start(composition, outputDir.newFile().getPath());
@ -567,7 +571,11 @@ public final class SequenceExportTest {
Transformer transformer =
new TestTransformerBuilder(context).setMuxerFactory(muxerFactory).build();
EditedMediaItemSequence sequence =
new EditedMediaItemSequence.Builder().addGap(300_000).addGap(200_000).build();
new EditedMediaItemSequence.Builder()
.addGap(300_000)
.addGap(200_000)
.setForceAudioTrack(true)
.build();
Composition composition = new Composition.Builder(sequence).build();
transformer.start(composition, outputDir.newFile().getPath());
@ -614,6 +622,7 @@ public final class SequenceExportTest {
.addGap(200_000)
.addGap(500_000)
.addItem(audioItem)
.setForceAudioTrack(true)
.build();
Composition composition = new Composition.Builder(sequence).build();
@ -709,6 +718,7 @@ public final class SequenceExportTest {
.addItem(firstAudioItem)
.addGap(200_000)
.addItem(secondAudioItem)
.setForceAudioTrack(true)
.build();
Composition composition = new Composition.Builder(sequence).build();