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 * Let `AdsMediaSource` load preroll ads before initial content media
preparation completes preparation completes
([#1358](https://github.com/androidx/media/issues/1358)). ([#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: * Transformer:
* Work around a decoder bug where the number of audio channels was capped * Work around a decoder bug where the number of audio channels was capped
at stereo when handling PCM input. at stereo when handling PCM input.

View File

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

View File

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

View File

@ -200,11 +200,12 @@ public final class MaskingMediaSource extends WrappingMediaSource {
: MaskingTimeline.createWithRealTimeline(newTimeline, windowUid, periodUid); : MaskingTimeline.createWithRealTimeline(newTimeline, windowUid, periodUid);
if (unpreparedMaskingMediaPeriod != null) { if (unpreparedMaskingMediaPeriod != null) {
MaskingMediaPeriod maskingPeriod = unpreparedMaskingMediaPeriod; MaskingMediaPeriod maskingPeriod = unpreparedMaskingMediaPeriod;
setPreparePositionOverrideToUnpreparedMaskingPeriod(periodPositionUs); if (setPreparePositionOverrideToUnpreparedMaskingPeriod(periodPositionUs)) {
idForMaskingPeriodPreparation = idForMaskingPeriodPreparation =
maskingPeriod.id.copyWithPeriodUid(getInternalPeriodUid(maskingPeriod.id.periodUid)); maskingPeriod.id.copyWithPeriodUid(getInternalPeriodUid(maskingPeriod.id.periodUid));
} }
} }
}
hasRealTimeline = true; hasRealTimeline = true;
isPrepared = true; isPrepared = true;
refreshSourceInfo(this.timeline); refreshSourceInfo(this.timeline);
@ -235,7 +236,8 @@ public final class MaskingMediaSource extends WrappingMediaSource {
} }
@RequiresNonNull("unpreparedMaskingMediaPeriod") @RequiresNonNull("unpreparedMaskingMediaPeriod")
private void setPreparePositionOverrideToUnpreparedMaskingPeriod(long preparePositionOverrideUs) { private boolean setPreparePositionOverrideToUnpreparedMaskingPeriod(
long preparePositionOverrideUs) {
MaskingMediaPeriod maskingPeriod = unpreparedMaskingMediaPeriod; MaskingMediaPeriod maskingPeriod = unpreparedMaskingMediaPeriod;
int maskingPeriodIndex = timeline.getIndexOfPeriod(maskingPeriod.id.periodUid); int maskingPeriodIndex = timeline.getIndexOfPeriod(maskingPeriod.id.periodUid);
if (maskingPeriodIndex == C.INDEX_UNSET) { 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 // 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 // update, as the non-existing period will be released anyway as soon as the player receives
// this new timeline. // this new timeline.
return; return false;
} }
long periodDurationUs = timeline.getPeriod(maskingPeriodIndex, period).durationUs; long periodDurationUs = timeline.getPeriod(maskingPeriodIndex, period).durationUs;
if (periodDurationUs != C.TIME_UNSET) { if (periodDurationUs != C.TIME_UNSET) {
@ -253,6 +255,7 @@ public final class MaskingMediaSource extends WrappingMediaSource {
} }
} }
maskingPeriod.overridePreparePositionUs(preparePositionOverrideUs); 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.FakeMediaPeriod;
import androidx.media3.test.utils.FakeMediaSource; import androidx.media3.test.utils.FakeMediaSource;
import androidx.media3.test.utils.FakeMediaSourceFactory; import androidx.media3.test.utils.FakeMediaSourceFactory;
import androidx.media3.test.utils.FakeMultiPeriodLiveTimeline;
import androidx.media3.test.utils.FakeRenderer; import androidx.media3.test.utils.FakeRenderer;
import androidx.media3.test.utils.FakeSampleStream; import androidx.media3.test.utils.FakeSampleStream;
import androidx.media3.test.utils.FakeSampleStream.FakeSampleStreamItem; import androidx.media3.test.utils.FakeSampleStream.FakeSampleStreamItem;
@ -14974,6 +14975,112 @@ public class ExoPlayerTest {
.inOrder(); .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. // Internal methods.
private void addWatchAsSystemFeature() { private void addWatchAsSystemFeature() {