Skip to content

Commit

Permalink
Handle timeline updates where all periods in window have been replaced
Browse files Browse the repository at this point in the history
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: #1329
PiperOrigin-RevId: 634833030
  • Loading branch information
tonihei authored and Copybara-Service committed May 17, 2024
1 parent eca6cb2 commit dd7fb81
Show file tree
Hide file tree
Showing 5 changed files with 145 additions and 30 deletions.
3 changes: 3 additions & 0 deletions RELEASENOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2617,17 +2617,15 @@ private Pair<Object, Long> getPeriodPositionUsAfterTimelineChanged(
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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2904,8 +2904,7 @@ private static PositionUpdateForPlaylistChange resolvePositionForPlaylistChange(
} 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,
Expand All @@ -2914,15 +2913,14 @@ private static PositionUpdateForPlaylistChange resolvePositionForPlaylistChange(
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
Expand Down Expand Up @@ -3219,8 +3217,7 @@ private static Pair<Object, Long> resolveSeekPositionUs(
}
if (trySubsequentPeriods) {
// Try and find a subsequent period from the seek timeline in the internal timeline.
@Nullable
Object periodUid =
int newWindowIndex =
resolveSubsequentPeriod(
window,
period,
Expand All @@ -3229,22 +3226,19 @@ private static Pair<Object, Long> resolveSeekPositionUs(
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.
return null;
}

/**
* 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.
Expand All @@ -3253,18 +3247,26 @@ private static Pair<Object, Long> resolveSeekPositionUs(
* @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,
boolean shuffleModeEnabled,
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();
Expand All @@ -3278,7 +3280,9 @@ static Object resolveSubsequentPeriod(
}
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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,9 +200,10 @@ protected void onChildSourceInfoRefreshed(Timeline newTimeline) {
: 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;
Expand Down Expand Up @@ -235,15 +236,16 @@ private Object getExternalPeriodUid(Object internalPeriodUid) {
}

@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) {
// The new timeline doesn't contain this period anymore. This can happen if the media source
// 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) {
Expand All @@ -253,6 +255,7 @@ private void setPreparePositionOverrideToUnpreparedMaskingPeriod(long preparePos
}
}
maskingPeriod.overridePreparePositionUs(preparePositionOverrideUs);
return true;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@
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;
Expand Down Expand Up @@ -14974,6 +14975,112 @@ public void handleMessage(@MessageType int messageType, @Nullable Object message
.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() {
Expand Down

0 comments on commit dd7fb81

Please sign in to comment.