Handle timeline updates where all periods in window have been replaced

This case is most likely to happen when re-preparing a multi-period
live stream after an error. The live timeline can easily move on to
new periods in the meantime, creating this type of update.

The behavior before this change has two bugs:
 - The player resolves the new start position to a subsequent period
   that existed in the old timeline, or ends playback if that cannot
   be found. The more useful behavior is to restart playback in the
   same live item if it still exists.
-  MaskingMediaSource creates a pending MaskingMediaPeriod using the
   old timeline and then attempts to create the real period from the
   updated source. This fails because MediaSource.createPeriod is
   called with a periodUid that does no longer exist at this point.
   We already have logic to not override the start position and need
   to extend this to also not prepare the real source.

Issue: androidx/media#1329
PiperOrigin-RevId: 634833030
This commit is contained in:
tonihei 2024-05-17 11:13:34 -07:00 committed by Copybara-Service
parent eca6cb23d8
commit dd7fb8178a
5 changed files with 145 additions and 30 deletions

View File

@ -35,6 +35,9 @@
* Let `AdsMediaSource` load preroll ads before initial content media
preparation completes
([#1358](https://github.com/androidx/media/issues/1358)).
* Fix bug where playback moved to `STATE_ENDED` when re-preparing a
multi-period DASH live stream after the original period was already
removed from the manifest.
* Transformer:
* Work around a decoder bug where the number of audio channels was capped
at stereo when handling PCM input.

View File

@ -2617,17 +2617,15 @@ import java.util.concurrent.TimeoutException;
return oldPeriodPositionUs;
}
// Period uid not found in new timeline. Try to get subsequent period.
@Nullable
Object nextPeriodUid =
int newWindowIndex =
ExoPlayerImplInternal.resolveSubsequentPeriod(
window, period, repeatMode, shuffleModeEnabled, periodUid, oldTimeline, newTimeline);
if (nextPeriodUid != null) {
if (newWindowIndex != C.INDEX_UNSET) {
// Reset position to the default position of the window of the subsequent period.
newTimeline.getPeriodByUid(nextPeriodUid, period);
return maskWindowPositionMsOrGetPeriodPositionUs(
newTimeline,
period.windowIndex,
newTimeline.getWindow(period.windowIndex, window).getDefaultPositionMs());
newWindowIndex,
newTimeline.getWindow(newWindowIndex, window).getDefaultPositionMs());
} else {
// No subsequent period found and the new timeline is not empty. Use the default position.
return maskWindowPositionMsOrGetPeriodPositionUs(

View File

@ -2904,8 +2904,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
} else if (timeline.getIndexOfPeriod(newPeriodUid) == C.INDEX_UNSET) {
// The current period isn't in the new timeline. Attempt to resolve a subsequent period whose
// window we can restart from.
@Nullable
Object subsequentPeriodUid =
int newWindowIndex =
resolveSubsequentPeriod(
window,
period,
@ -2914,15 +2913,14 @@ import java.util.concurrent.atomic.AtomicBoolean;
newPeriodUid,
playbackInfo.timeline,
timeline);
if (subsequentPeriodUid == null) {
if (newWindowIndex == C.INDEX_UNSET) {
// We failed to resolve a suitable restart position but the timeline is not empty.
endPlayback = true;
startAtDefaultPositionWindowIndex = timeline.getFirstWindowIndex(shuffleModeEnabled);
} else {
// We resolved a subsequent period. Start at the default position in the corresponding
// window.
startAtDefaultPositionWindowIndex =
timeline.getPeriodByUid(subsequentPeriodUid, period).windowIndex;
startAtDefaultPositionWindowIndex = newWindowIndex;
}
} else if (oldContentPositionUs == C.TIME_UNSET) {
// The content was requested to start from its default position and we haven't used the
@ -3219,8 +3217,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
}
if (trySubsequentPeriods) {
// Try and find a subsequent period from the seek timeline in the internal timeline.
@Nullable
Object periodUid =
int newWindowIndex =
resolveSubsequentPeriod(
window,
period,
@ -3229,13 +3226,10 @@ import java.util.concurrent.atomic.AtomicBoolean;
periodPositionUs.first,
seekTimeline,
timeline);
if (periodUid != null) {
if (newWindowIndex != C.INDEX_UNSET) {
// We found one. Use the default position of the corresponding window.
return timeline.getPeriodPositionUs(
window,
period,
timeline.getPeriodByUid(periodUid, period).windowIndex,
/* windowPositionUs= */ C.TIME_UNSET);
window, period, newWindowIndex, /* windowPositionUs= */ C.TIME_UNSET);
}
}
// We didn't find one. Give up.
@ -3243,8 +3237,8 @@ import java.util.concurrent.atomic.AtomicBoolean;
}
/**
* Given a period index into an old timeline, finds the first subsequent period that also exists
* in a new timeline. The uid of this period in the new timeline is returned.
* Given a period index into an old timeline, searches for suitable subsequent periods in the new
* timeline and returns their window index if found.
*
* @param window A {@link Timeline.Window} to be used internally.
* @param period A {@link Timeline.Period} to be used internally.
@ -3253,11 +3247,10 @@ import java.util.concurrent.atomic.AtomicBoolean;
* @param oldPeriodUid The index of the period in the old timeline.
* @param oldTimeline The old timeline.
* @param newTimeline The new timeline.
* @return The uid in the new timeline of the first subsequent period, or null if no such period
* was found.
* @return The most suitable window index in the new timeline to continue playing from, or {@link
* C#INDEX_UNSET} if none was found.
*/
/* package */ @Nullable
static Object resolveSubsequentPeriod(
/* package */ static int resolveSubsequentPeriod(
Timeline.Window window,
Timeline.Period period,
@Player.RepeatMode int repeatMode,
@ -3265,6 +3258,15 @@ import java.util.concurrent.atomic.AtomicBoolean;
Object oldPeriodUid,
Timeline oldTimeline,
Timeline newTimeline) {
int oldWindowIndex = oldTimeline.getPeriodByUid(oldPeriodUid, period).windowIndex;
Object oldWindowUid = oldTimeline.getWindow(oldWindowIndex, window).uid;
// TODO: b/341049911 - Use more efficient UID based access rather than a full search.
for (int i = 0; i < newTimeline.getWindowCount(); i++) {
if (newTimeline.getWindow(/* windowIndex= */ i, window).uid.equals(oldWindowUid)) {
// Window still exists, resume from there.
return i;
}
}
int oldPeriodIndex = oldTimeline.getIndexOfPeriod(oldPeriodUid);
int newPeriodIndex = C.INDEX_UNSET;
int maxIterations = oldTimeline.getPeriodCount();
@ -3278,7 +3280,9 @@ import java.util.concurrent.atomic.AtomicBoolean;
}
newPeriodIndex = newTimeline.getIndexOfPeriod(oldTimeline.getUidOfPeriod(oldPeriodIndex));
}
return newPeriodIndex == C.INDEX_UNSET ? null : newTimeline.getUidOfPeriod(newPeriodIndex);
return newPeriodIndex == C.INDEX_UNSET
? C.INDEX_UNSET
: newTimeline.getPeriod(newPeriodIndex, period).windowIndex;
}
private static Format[] getFormats(ExoTrackSelection newSelection) {

View File

@ -200,9 +200,10 @@ public final class MaskingMediaSource extends WrappingMediaSource {
: MaskingTimeline.createWithRealTimeline(newTimeline, windowUid, periodUid);
if (unpreparedMaskingMediaPeriod != null) {
MaskingMediaPeriod maskingPeriod = unpreparedMaskingMediaPeriod;
setPreparePositionOverrideToUnpreparedMaskingPeriod(periodPositionUs);
idForMaskingPeriodPreparation =
maskingPeriod.id.copyWithPeriodUid(getInternalPeriodUid(maskingPeriod.id.periodUid));
if (setPreparePositionOverrideToUnpreparedMaskingPeriod(periodPositionUs)) {
idForMaskingPeriodPreparation =
maskingPeriod.id.copyWithPeriodUid(getInternalPeriodUid(maskingPeriod.id.periodUid));
}
}
}
hasRealTimeline = true;
@ -235,7 +236,8 @@ public final class MaskingMediaSource extends WrappingMediaSource {
}
@RequiresNonNull("unpreparedMaskingMediaPeriod")
private void setPreparePositionOverrideToUnpreparedMaskingPeriod(long preparePositionOverrideUs) {
private boolean setPreparePositionOverrideToUnpreparedMaskingPeriod(
long preparePositionOverrideUs) {
MaskingMediaPeriod maskingPeriod = unpreparedMaskingMediaPeriod;
int maskingPeriodIndex = timeline.getIndexOfPeriod(maskingPeriod.id.periodUid);
if (maskingPeriodIndex == C.INDEX_UNSET) {
@ -243,7 +245,7 @@ public final class MaskingMediaSource extends WrappingMediaSource {
// has multiple periods and removed the first period with a timeline update. Ignore the
// update, as the non-existing period will be released anyway as soon as the player receives
// this new timeline.
return;
return false;
}
long periodDurationUs = timeline.getPeriod(maskingPeriodIndex, period).durationUs;
if (periodDurationUs != C.TIME_UNSET) {
@ -253,6 +255,7 @@ public final class MaskingMediaSource extends WrappingMediaSource {
}
}
maskingPeriod.overridePreparePositionUs(preparePositionOverrideUs);
return true;
}
/**

View File

@ -182,6 +182,7 @@ import androidx.media3.test.utils.FakeMediaClockRenderer;
import androidx.media3.test.utils.FakeMediaPeriod;
import androidx.media3.test.utils.FakeMediaSource;
import androidx.media3.test.utils.FakeMediaSourceFactory;
import androidx.media3.test.utils.FakeMultiPeriodLiveTimeline;
import androidx.media3.test.utils.FakeRenderer;
import androidx.media3.test.utils.FakeSampleStream;
import androidx.media3.test.utils.FakeSampleStream.FakeSampleStreamItem;
@ -14974,6 +14975,112 @@ public class ExoPlayerTest {
.inOrder();
}
@Test
public void timelineUpdate_currentWindowNoLongerExists_movesToNextWindow() throws Exception {
FakeTimeline timeline1 =
new FakeTimeline(new TimelineWindowDefinition(/* periodCount= */ 1, /* id= */ "a"));
FakeTimeline timeline2 =
new FakeTimeline(new TimelineWindowDefinition(/* periodCount= */ 1, /* id= */ "b"));
ExoPlayer player = new TestExoPlayerBuilder(context).build();
FakeMediaSource fakeMediaSource = new FakeMediaSource(timeline1);
player.setMediaSources(ImmutableList.of(fakeMediaSource, new FakeMediaSource()));
player.prepare();
run(player).untilState(Player.STATE_READY);
fakeMediaSource.setNewSourceInfo(timeline2);
run(player).untilTimelineChanges();
int windowIndexAfterUpdate = player.getCurrentMediaItemIndex();
player.release();
assertThat(windowIndexAfterUpdate).isEqualTo(1);
}
@Test
public void timelineUpdate_allPeriodsInCurrentWindowChange_keepsCurrentWindow() throws Exception {
FakeMultiPeriodLiveTimeline liveTimeline1 =
new FakeMultiPeriodLiveTimeline(
/* availabilityStartTimeMs= */ 0L,
/* liveWindowDurationUs= */ 10 * C.MICROS_PER_SECOND,
/* nowUs= */ 10 * C.MICROS_PER_SECOND,
/* adSequencePattern= */ new boolean[] {false},
/* periodDurationMsPattern= */ new long[] {5000},
/* isContentTimeline= */ true,
/* populateAds= */ false,
/* playedAds= */ false);
FakeMultiPeriodLiveTimeline liveTimeline2 =
new FakeMultiPeriodLiveTimeline(
/* availabilityStartTimeMs= */ 0L,
/* liveWindowDurationUs= */ 10 * C.MICROS_PER_SECOND,
/* nowUs= */ 10 * C.MICROS_PER_SECOND,
/* adSequencePattern= */ new boolean[] {false},
/* periodDurationMsPattern= */ new long[] {5000},
/* isContentTimeline= */ true,
/* populateAds= */ false,
/* playedAds= */ false);
liveTimeline2.advanceNowUs(20 * C.MICROS_PER_SECOND);
ExoPlayer player = new TestExoPlayerBuilder(context).build();
FakeMediaSource liveSource = new FakeMediaSource(liveTimeline1);
player.setMediaSources(ImmutableList.of(liveSource, new FakeMediaSource()));
player.prepare();
run(player).untilState(Player.STATE_READY);
liveSource.setNewSourceInfo(liveTimeline2);
run(player).untilTimelineChanges();
int windowIndexAfterUpdate = player.getCurrentMediaItemIndex();
player.release();
assertThat(windowIndexAfterUpdate).isEqualTo(0);
}
@Test
public void playbackErrorAndReprepare_withLiveTimelineAllPeriodsReplaced_keepsPlayingLiveSource()
throws Exception {
FakeMultiPeriodLiveTimeline liveTimeline1 =
new FakeMultiPeriodLiveTimeline(
/* availabilityStartTimeMs= */ 0L,
/* liveWindowDurationUs= */ 10 * C.MICROS_PER_SECOND,
/* nowUs= */ 10 * C.MICROS_PER_SECOND,
/* adSequencePattern= */ new boolean[] {false},
/* periodDurationMsPattern= */ new long[] {5000},
/* isContentTimeline= */ true,
/* populateAds= */ false,
/* playedAds= */ false);
FakeMultiPeriodLiveTimeline liveTimeline2 =
new FakeMultiPeriodLiveTimeline(
/* availabilityStartTimeMs= */ 0L,
/* liveWindowDurationUs= */ 10 * C.MICROS_PER_SECOND,
/* nowUs= */ 10 * C.MICROS_PER_SECOND,
/* adSequencePattern= */ new boolean[] {false},
/* periodDurationMsPattern= */ new long[] {5000},
/* isContentTimeline= */ true,
/* populateAds= */ false,
/* playedAds= */ false);
liveTimeline2.advanceNowUs(20 * C.MICROS_PER_SECOND);
ExoPlayer player = new TestExoPlayerBuilder(context).build();
FakeMediaSource liveSource = new FakeMediaSource(liveTimeline1);
player.setMediaSources(ImmutableList.of(liveSource, new FakeMediaSource()));
player.prepare();
run(player).untilState(Player.STATE_READY);
player
.createMessage(
(message, payload) -> {
throw new IllegalStateException();
})
.send();
run(player).untilPlayerError();
liveSource.setNewSourceInfo(liveTimeline2);
liveSource.setAllowPreparation(false); // Lazily update timeline to simulate new manifest load
player.prepare();
run(player).untilPendingCommandsAreFullyHandled();
liveSource.setAllowPreparation(true);
run(player).untilState(Player.STATE_READY);
int mediaItemIndexAfterReprepare = player.getCurrentMediaItemIndex();
player.release();
assertThat(mediaItemIndexAfterReprepare).isEqualTo(0);
}
// Internal methods.
private void addWatchAsSystemFeature() {