diff --git a/RELEASENOTES.md b/RELEASENOTES.md index f94c7f9990..30ce2b58bf 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -59,6 +59,12 @@ The new `DefaultLoadControl.calculateTargetBufferBytes(ExoTrackSelection[])` should be used instead. + * Report `MediaSourceEventListener` events from secondary sources in + `MergingMediaSource`. This will result in load + start/error/cancelled/completed events being reported for sideloaded + subtitles (those added with + `MediaItem.LocalConfiguration.subtitleConfigurations`), which may appear + as duplicate load events emitted from `AnalyticsListener`. * Transformer: * Make setting the image duration using `MediaItem.Builder.setImageDurationMs` mandatory for image export. diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/MergingMediaSource.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/MergingMediaSource.java index 7479396c66..1b0d5c21ad 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/MergingMediaSource.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/MergingMediaSource.java @@ -38,6 +38,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; /** @@ -84,6 +85,7 @@ public final class MergingMediaSource extends CompositeMediaSource { private final boolean adjustPeriodTimeOffsets; private final boolean clipDurations; private final MediaSource[] mediaSources; + private final List> mediaPeriods; private final Timeline[] timelines; private final ArrayList pendingTimelineSources; private final CompositeSequenceableLoaderFactory compositeSequenceableLoaderFactory; @@ -161,6 +163,10 @@ public final class MergingMediaSource extends CompositeMediaSource { this.compositeSequenceableLoaderFactory = compositeSequenceableLoaderFactory; pendingTimelineSources = new ArrayList<>(Arrays.asList(mediaSources)); periodCount = PERIOD_COUNT_UNSET; + this.mediaPeriods = new ArrayList<>(mediaSources.length); + for (int i = 0; i < mediaSources.length; i++) { + mediaPeriods.add(new ArrayList<>()); + } timelines = new Timeline[mediaSources.length]; periodTimeOffsetsUs = new long[0][]; clippedDurationsUs = new HashMap<>(); @@ -203,11 +209,11 @@ public final class MergingMediaSource extends CompositeMediaSource { MediaPeriod[] periods = new MediaPeriod[mediaSources.length]; int periodIndex = timelines[0].getIndexOfPeriod(id.periodUid); for (int i = 0; i < periods.length; i++) { - MediaPeriodId childMediaPeriodId = - id.copyWithPeriodUid(timelines[i].getUidOfPeriod(periodIndex)); + MediaPeriodId mediaPeriodId = id.copyWithPeriodUid(timelines[i].getUidOfPeriod(periodIndex)); periods[i] = mediaSources[i].createPeriod( - childMediaPeriodId, allocator, startPositionUs - periodTimeOffsetsUs[periodIndex][i]); + mediaPeriodId, allocator, startPositionUs - periodTimeOffsetsUs[periodIndex][i]); + mediaPeriods.get(i).add(new MediaPeriodAndId(mediaPeriodId, periods[i])); } MediaPeriod mediaPeriod = new MergingMediaPeriod( @@ -238,6 +244,13 @@ public final class MergingMediaSource extends CompositeMediaSource { } MergingMediaPeriod mergingPeriod = (MergingMediaPeriod) mediaPeriod; for (int i = 0; i < mediaSources.length; i++) { + List mediaPeriodsForSource = mediaPeriods.get(i); + for (int j = 0; j < mediaPeriodsForSource.size(); j++) { + if (mediaPeriodsForSource.get(j).mediaPeriod.equals(mediaPeriod)) { + mediaPeriodsForSource.remove(j); + break; + } + } mediaSources[i].releasePeriod(mergingPeriod.getChildPeriod(i)); } } @@ -286,7 +299,13 @@ public final class MergingMediaSource extends CompositeMediaSource { @Nullable protected MediaPeriodId getMediaPeriodIdForChildMediaPeriodId( Integer childSourceId, MediaPeriodId mediaPeriodId) { - return childSourceId == 0 ? mediaPeriodId : null; + List childMediaPeriodIds = mediaPeriods.get(childSourceId); + for (int i = 0; i < childMediaPeriodIds.size(); i++) { + if (childMediaPeriodIds.get(i).mediaPeriodId.equals(mediaPeriodId)) { + return mediaPeriods.get(0).get(i).mediaPeriodId; + } + } + return null; } private void computePeriodTimeOffsets() { @@ -370,4 +389,14 @@ public final class MergingMediaSource extends CompositeMediaSource { return period; } } + + private static final class MediaPeriodAndId { + private final MediaPeriodId mediaPeriodId; + private final MediaPeriod mediaPeriod; + + private MediaPeriodAndId(MediaPeriodId mediaPeriodId, MediaPeriod mediaPeriod) { + this.mediaPeriodId = mediaPeriodId; + this.mediaPeriod = mediaPeriod; + } + } } diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/MergingMediaSourceTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/MergingMediaSourceTest.java index f45adbd4a5..b39328c399 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/MergingMediaSourceTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/source/MergingMediaSourceTest.java @@ -15,18 +15,28 @@ */ package androidx.media3.exoplayer.source; +import static androidx.media3.test.utils.robolectric.RobolectricUtil.DEFAULT_TIMEOUT_MS; import static com.google.common.truth.Truth.assertThat; +import static java.util.concurrent.TimeUnit.MILLISECONDS; import static org.junit.Assert.assertThrows; +import androidx.annotation.Nullable; import androidx.media3.common.C; import androidx.media3.common.Timeline; +import androidx.media3.common.util.Util; +import androidx.media3.exoplayer.source.MediaSource.MediaPeriodId; import androidx.media3.exoplayer.source.MergingMediaSource.IllegalMergeException; +import androidx.media3.test.utils.FakeMediaPeriod; import androidx.media3.test.utils.FakeMediaSource; import androidx.media3.test.utils.FakeTimeline; import androidx.media3.test.utils.FakeTimeline.TimelineWindowDefinition; import androidx.media3.test.utils.MediaSourceTestRunner; import androidx.test.ext.junit.runners.AndroidJUnit4; +import com.google.common.collect.ConcurrentHashMultiset; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Multiset; import java.io.IOException; +import java.util.concurrent.CountDownLatch; import org.junit.Test; import org.junit.runner.RunWith; @@ -112,6 +122,102 @@ public class MergingMediaSourceTest { } } + /** + * Assert that events from all child sources are propagated, but always reported with a {@link + * MediaPeriodId} that can be resolved against the {@link Timeline} exposed by the parent {@link + * MergingMediaSource} (these are period IDs from the first child source). + */ + @Test + public void eventsFromAllChildrenPropagated_alwaysAssociatedWithPrimaryPeriodId() + throws Exception { + Multiset onLoadStartedMediaPeriodUids = ConcurrentHashMultiset.create(); + Multiset onLoadCompletedMediaPeriodUids = ConcurrentHashMultiset.create(); + MediaSourceEventListener mediaSourceEventListener = + new MediaSourceEventListener() { + @Override + public void onLoadStarted( + int windowIndex, + @Nullable MediaPeriodId mediaPeriodId, + LoadEventInfo loadEventInfo, + MediaLoadData mediaLoadData) { + if (mediaPeriodId != null) { + onLoadStartedMediaPeriodUids.add(mediaPeriodId.periodUid); + } + } + + @Override + public void onLoadCompleted( + int windowIndex, + @Nullable MediaPeriodId mediaPeriodId, + LoadEventInfo loadEventInfo, + MediaLoadData mediaLoadData) { + if (mediaPeriodId != null) { + onLoadCompletedMediaPeriodUids.add(mediaPeriodId.periodUid); + } + } + }; + FakeMediaSource[] childMediaSources = new FakeMediaSource[2]; + for (int i = 0; i < childMediaSources.length; i++) { + childMediaSources[i] = + new FakeMediaSource( + new FakeTimeline( + new FakeTimeline.TimelineWindowDefinition(/* periodCount= */ 2, /* id= */ i))); + } + // Delay child1's period preparation, so we can delay child1period0 preparation completion until + // after period1 has been created and prepared. + childMediaSources[1].setPeriodDefersOnPreparedCallback(true); + MergingMediaSource mergingMediaSource = new MergingMediaSource(childMediaSources); + MediaSourceTestRunner testRunner = new MediaSourceTestRunner(mergingMediaSource); + try { + testRunner.runOnPlaybackThread( + () -> + mergingMediaSource.addEventListener( + Util.createHandlerForCurrentLooper(), mediaSourceEventListener)); + Timeline timeline = testRunner.prepareSource(); + MediaPeriod mergedMediaPeriod0 = + testRunner.createPeriod(new MediaPeriodId(timeline.getUidOfPeriod(/* periodIndex= */ 0))); + FakeMediaPeriod childSource1Period0 = + (FakeMediaPeriod) childMediaSources[1].getLastCreatedActiveMediaPeriod(); + MediaPeriod mergedMediaPeriod1 = + testRunner.createPeriod(new MediaPeriodId(timeline.getUidOfPeriod(/* periodIndex= */ 1))); + // Prepare period0 after period1 has been created to ensure that MergingMediaSource correctly + // attributes and propagates the associated onLoadStarted event. + CountDownLatch preparedLatch0 = + testRunner.preparePeriod(mergedMediaPeriod0, /* positionUs= */ 0); + CountDownLatch preparedLatch1 = + testRunner.preparePeriod(mergedMediaPeriod1, /* positionUs= */ 0); + // Complete child1period0 preparation after period1 has been created to ensure that + // MergingMediaSource correctly attributes and propagates the associated onLoadCompleted + // event. + childSource1Period0.setPreparationComplete(); + ((FakeMediaPeriod) childMediaSources[1].getLastCreatedActiveMediaPeriod()) + .setPreparationComplete(); + + assertThat(preparedLatch0.await(DEFAULT_TIMEOUT_MS, MILLISECONDS)).isTrue(); + assertThat(preparedLatch1.await(DEFAULT_TIMEOUT_MS, MILLISECONDS)).isTrue(); + testRunner.releasePeriod(mergedMediaPeriod0); + testRunner.releasePeriod(mergedMediaPeriod1); + for (FakeMediaSource element : childMediaSources) { + assertThat(element.getCreatedMediaPeriods()).isNotEmpty(); + } + testRunner.releaseSource(); + ImmutableList.Builder expectedMediaPeriodUids = + ImmutableList.builderWithExpectedSize(onLoadStartedMediaPeriodUids.size()); + for (int i = 0; i < timeline.getPeriodCount(); i++) { + Object periodUid = timeline.getUidOfPeriod(i); + // Add each period UID twice, because each child reports its own load events (but both are + // reported with the same MediaPeriodId out of MergingMediaSource). + expectedMediaPeriodUids.add(periodUid).add(periodUid); + } + assertThat(onLoadStartedMediaPeriodUids) + .containsExactlyElementsIn(expectedMediaPeriodUids.build()); + assertThat(onLoadCompletedMediaPeriodUids) + .containsExactlyElementsIn(expectedMediaPeriodUids.build()); + } finally { + testRunner.release(); + } + } + /** * Wraps the specified timelines in a {@link MergingMediaSource}, prepares it and returns the * merged timeline.