mirror of
https://github.com/androidx/media.git
synced 2025-04-30 06:46:50 +08:00
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:
parent
eca6cb23d8
commit
dd7fb8178a
@ -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.
|
||||||
|
@ -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(
|
||||||
|
@ -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) {
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -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() {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user