Use correct period-window offset for initial prepare position.

MaskingMediaSource needs to resolve the prepare position set for a MaskingPeriod
while the source was still unprepared to the first actual prepare position.

It currently assumes that the period-window offset and the default position is
zero. This assumption is correct when a PlaceholderTimeline is used, but it
may not be true if the real timeline is already known (e.g. when re-preparing
a live stream after a playback error).

Fix this by using the known timeline at the time of the preparation.
Also:
 - Update a test that should have caught this to use lazy re-preparation.
 - Change the demo app code to use the recommended way to restart playback
   after a BehindLiveWindowException.

Issue: #8675
PiperOrigin-RevId: 361604191
This commit is contained in:
tonihei 2021-03-08 18:45:20 +00:00 committed by Ian Baker
parent ea0f72e46c
commit bc9fb8615e
5 changed files with 72 additions and 54 deletions

View File

@ -27,6 +27,9 @@
`ExoPlayer`. `ExoPlayer`.
* Reset playback speed when live playback speed control becomes unused * Reset playback speed when live playback speed control becomes unused
([#8664](https://github.com/google/ExoPlayer/issues/8664)). ([#8664](https://github.com/google/ExoPlayer/issues/8664)).
* Fix playback position issue when re-preparing playback after a
BehindLiveWindowException
([#8675](https://github.com/google/ExoPlayer/issues/8675)).
* Remove deprecated symbols: * Remove deprecated symbols:
* Remove `Player.DefaultEventListener`. Use `Player.EventListener` * Remove `Player.DefaultEventListener`. Use `Player.EventListener`
instead. instead.

View File

@ -440,8 +440,8 @@ public class PlayerActivity extends AppCompatActivity
@Override @Override
public void onPlayerError(@NonNull ExoPlaybackException e) { public void onPlayerError(@NonNull ExoPlaybackException e) {
if (isBehindLiveWindow(e)) { if (isBehindLiveWindow(e)) {
clearStartPosition(); player.seekToDefaultPosition();
initializePlayer(); player.prepare();
} else { } else {
updateButtonVisibility(); updateButtonVisibility();
showControls(); showControls();

View File

@ -178,13 +178,17 @@ public final class MaskingMediaSource extends CompositeMediaSource<Void> {
// anyway. // anyway.
newTimeline.getWindow(/* windowIndex= */ 0, window); newTimeline.getWindow(/* windowIndex= */ 0, window);
long windowStartPositionUs = window.getDefaultPositionUs(); long windowStartPositionUs = window.getDefaultPositionUs();
Object windowUid = window.uid;
if (unpreparedMaskingMediaPeriod != null) { if (unpreparedMaskingMediaPeriod != null) {
long periodPreparePositionUs = unpreparedMaskingMediaPeriod.getPreparePositionUs(); long periodPreparePositionUs = unpreparedMaskingMediaPeriod.getPreparePositionUs();
if (periodPreparePositionUs != 0) { timeline.getPeriodByUid(unpreparedMaskingMediaPeriod.id.periodUid, period);
windowStartPositionUs = periodPreparePositionUs; long windowPreparePositionUs = period.getPositionInWindowUs() + periodPreparePositionUs;
long oldWindowDefaultPositionUs =
timeline.getWindow(/* windowIndex= */ 0, window).getDefaultPositionUs();
if (windowPreparePositionUs != oldWindowDefaultPositionUs) {
windowStartPositionUs = windowPreparePositionUs;
} }
} }
Object windowUid = window.uid;
Pair<Object, Long> periodPosition = Pair<Object, Long> periodPosition =
newTimeline.getPeriodPosition( newTimeline.getPeriodPosition(
window, period, /* windowIndex= */ 0, windowStartPositionUs); window, period, /* windowIndex= */ 0, windowStartPositionUs);

View File

@ -17,6 +17,7 @@ package com.google.android.exoplayer2;
import static com.google.android.exoplayer2.robolectric.RobolectricUtil.runMainLooperUntil; import static com.google.android.exoplayer2.robolectric.RobolectricUtil.runMainLooperUntil;
import static com.google.android.exoplayer2.robolectric.TestPlayerRunHelper.playUntilStartOfWindow; import static com.google.android.exoplayer2.robolectric.TestPlayerRunHelper.playUntilStartOfWindow;
import static com.google.android.exoplayer2.robolectric.TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled;
import static com.google.android.exoplayer2.robolectric.TestPlayerRunHelper.runUntilPlaybackState; import static com.google.android.exoplayer2.robolectric.TestPlayerRunHelper.runUntilPlaybackState;
import static com.google.android.exoplayer2.robolectric.TestPlayerRunHelper.runUntilReceiveOffloadSchedulingEnabledNewState; import static com.google.android.exoplayer2.robolectric.TestPlayerRunHelper.runUntilReceiveOffloadSchedulingEnabledNewState;
import static com.google.android.exoplayer2.robolectric.TestPlayerRunHelper.runUntilSleepingForOffload; import static com.google.android.exoplayer2.robolectric.TestPlayerRunHelper.runUntilSleepingForOffload;
@ -1653,55 +1654,44 @@ public final class ExoPlayerTest {
} }
@Test @Test
public void seekAndReprepareAfterPlaybackError() throws Exception { public void seekAndReprepareAfterPlaybackError_keepsSeekPositionAndTimeline() throws Exception {
Timeline timeline = new FakeTimeline(); SimpleExoPlayer player = new TestExoPlayerBuilder(context).build();
final long[] positionHolder = new long[2]; Player.EventListener mockListener = mock(Player.EventListener.class);
ActionSchedule actionSchedule = player.addListener(mockListener);
new ActionSchedule.Builder(TAG) FakeMediaSource fakeMediaSource = new FakeMediaSource();
.pause() player.setMediaSource(fakeMediaSource);
.waitForPlaybackState(Player.STATE_READY)
.throwPlaybackException(ExoPlaybackException.createForSource(new IOException()))
.waitForPlaybackState(Player.STATE_IDLE)
.seek(/* positionMs= */ 50)
.waitForPendingPlayerCommands()
.executeRunnable(
new PlayerRunnable() {
@Override
public void run(SimpleExoPlayer player) {
positionHolder[0] = player.getCurrentPosition();
}
})
.prepare()
.waitForPlaybackState(Player.STATE_READY)
.executeRunnable(
new PlayerRunnable() {
@Override
public void run(SimpleExoPlayer player) {
positionHolder[1] = player.getCurrentPosition();
}
})
.play()
.build();
ExoPlayerTestRunner testRunner =
new ExoPlayerTestRunner.Builder(context)
.setTimeline(timeline)
.setActionSchedule(actionSchedule)
.build();
assertThrows( player.prepare();
ExoPlaybackException.class, runUntilPlaybackState(player, Player.STATE_READY);
() -> player
testRunner .createMessage(
.start() (type, payload) -> {
.blockUntilActionScheduleFinished(TIMEOUT_MS) throw ExoPlaybackException.createForSource(new IOException());
.blockUntilEnded(TIMEOUT_MS)); })
testRunner.assertTimelinesSame(placeholderTimeline, timeline); .send();
testRunner.assertTimelineChangeReasonsEqual( runUntilPlaybackState(player, Player.STATE_IDLE);
Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED, player.seekTo(/* positionMs= */ 50);
Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE); runUntilPendingCommandsAreFullyHandled(player);
testRunner.assertPositionDiscontinuityReasonsEqual(Player.DISCONTINUITY_REASON_SEEK); long positionAfterSeekHandled = player.getCurrentPosition();
assertThat(positionHolder[0]).isEqualTo(50); // Delay re-preparation to force player to use its masking mechanisms.
assertThat(positionHolder[1]).isEqualTo(50); fakeMediaSource.setAllowPreparation(false);
player.prepare();
runUntilPendingCommandsAreFullyHandled(player);
long positionAfterReprepareHandled = player.getCurrentPosition();
fakeMediaSource.setAllowPreparation(true);
runUntilPlaybackState(player, Player.STATE_READY);
long positionWhenFullyReadyAfterReprepare = player.getCurrentPosition();
player.release();
// Ensure we don't receive further timeline updates when repreparing.
verify(mockListener)
.onTimelineChanged(any(), eq(Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED));
verify(mockListener).onTimelineChanged(any(), eq(Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE));
verify(mockListener, times(2)).onTimelineChanged(any(), anyInt());
assertThat(positionAfterSeekHandled).isEqualTo(50);
assertThat(positionAfterReprepareHandled).isEqualTo(50);
assertThat(positionWhenFullyReadyAfterReprepare).isEqualTo(50);
} }
@Test @Test

View File

@ -15,6 +15,7 @@
*/ */
package com.google.android.exoplayer2.testutil; package com.google.android.exoplayer2.testutil;
import static com.google.android.exoplayer2.util.Assertions.checkState;
import static com.google.android.exoplayer2.util.Util.castNonNull; import static com.google.android.exoplayer2.util.Util.castNonNull;
import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth.assertThat;
@ -86,6 +87,7 @@ public class FakeMediaSource extends BaseMediaSource {
private final ArrayList<MediaPeriodId> createdMediaPeriods; private final ArrayList<MediaPeriodId> createdMediaPeriods;
private final DrmSessionManager drmSessionManager; private final DrmSessionManager drmSessionManager;
private boolean preparationAllowed;
private @MonotonicNonNull Timeline timeline; private @MonotonicNonNull Timeline timeline;
private boolean preparedSource; private boolean preparedSource;
private boolean releasedSource; private boolean releasedSource;
@ -154,6 +156,22 @@ public class FakeMediaSource extends BaseMediaSource {
this.createdMediaPeriods = new ArrayList<>(); this.createdMediaPeriods = new ArrayList<>();
this.drmSessionManager = drmSessionManager; this.drmSessionManager = drmSessionManager;
this.trackDataFactory = trackDataFactory; this.trackDataFactory = trackDataFactory;
preparationAllowed = true;
}
/**
* Sets whether the next call to {@link #prepareSource} is allowed to finish. If not allowed, a
* later call to this method with {@code allowPreparation} set to true will finish the
* preparation.
*
* @param allowPreparation Whether preparation is allowed to finish.
*/
public synchronized void setAllowPreparation(boolean allowPreparation) {
preparationAllowed = allowPreparation;
if (allowPreparation && sourceInfoRefreshHandler != null) {
sourceInfoRefreshHandler.post(
() -> finishSourcePreparation(/* sendManifestLoadEvents= */ true));
}
} }
@Nullable @Nullable
@ -204,7 +222,7 @@ public class FakeMediaSource extends BaseMediaSource {
preparedSource = true; preparedSource = true;
releasedSource = false; releasedSource = false;
sourceInfoRefreshHandler = Util.createHandlerForCurrentLooper(); sourceInfoRefreshHandler = Util.createHandlerForCurrentLooper();
if (timeline != null) { if (preparationAllowed && timeline != null) {
finishSourcePreparation(/* sendManifestLoadEvents= */ true); finishSourcePreparation(/* sendManifestLoadEvents= */ true);
} }
} }
@ -273,11 +291,14 @@ public class FakeMediaSource extends BaseMediaSource {
* Sets a new timeline. If the source is already prepared, this triggers a source info refresh * Sets a new timeline. If the source is already prepared, this triggers a source info refresh
* message being sent to the listener. * message being sent to the listener.
* *
* <p>Must only be called if preparation is {@link #setAllowPreparation(boolean) allowed}.
*
* @param newTimeline The new {@link Timeline}. * @param newTimeline The new {@link Timeline}.
* @param sendManifestLoadEvents Whether to treat this as a manifest refresh and send manifest * @param sendManifestLoadEvents Whether to treat this as a manifest refresh and send manifest
* load events to listeners. * load events to listeners.
*/ */
public synchronized void setNewSourceInfo(Timeline newTimeline, boolean sendManifestLoadEvents) { public synchronized void setNewSourceInfo(Timeline newTimeline, boolean sendManifestLoadEvents) {
checkState(preparationAllowed);
if (sourceInfoRefreshHandler != null) { if (sourceInfoRefreshHandler != null) {
sourceInfoRefreshHandler.post( sourceInfoRefreshHandler.post(
() -> { () -> {