Make current period a placeholder when a live stream is reset

In case the player is reset while a live stream is playing, the current
period needs to be a placeholder. This makes sure that the default start
position is used when the first live timeline arrives after re-preparing.

#minor-release

PiperOrigin-RevId: 539044360
(cherry picked from commit 71153a43a8e55380076af97450414f9142dc636b)
This commit is contained in:
bachinger 2023-06-09 11:38:29 +00:00 committed by Tofunmi Adigun-Hameed
parent 50f4caacd6
commit 56c62d1ab1
6 changed files with 310 additions and 18 deletions

View File

@ -1492,9 +1492,17 @@ import java.util.concurrent.atomic.AtomicBoolean;
queue.clear();
shouldContinueLoading = false;
Timeline timeline = playbackInfo.timeline;
if (releaseMediaSourceList && timeline instanceof PlaylistTimeline) {
// Wrap the current live timeline to make sure the current period is marked as a placeholder
// to force resolving the default start position with the next timeline refresh.
timeline =
((PlaylistTimeline) playbackInfo.timeline)
.copyWithPlaceholderTimeline(mediaSourceList.getShuffleOrder());
}
playbackInfo =
new PlaybackInfo(
playbackInfo.timeline,
timeline,
mediaPeriodId,
requestedContentPositionUs,
/* discontinuityStartPositionUs= */ startPositionUs,

View File

@ -365,6 +365,11 @@ import org.checkerframework.checker.nullness.compatqual.NullableType;
return new PlaylistTimeline(mediaSourceHolders, shuffleOrder);
}
/** Returns the shuffle order */
public ShuffleOrder getShuffleOrder() {
return shuffleOrder;
}
// Internal methods.
private void enableMediaSource(MediaSourceHolder mediaSourceHolder) {

View File

@ -18,6 +18,7 @@ package androidx.media3.exoplayer;
import androidx.media3.common.C;
import androidx.media3.common.Timeline;
import androidx.media3.common.util.Util;
import androidx.media3.exoplayer.source.ForwardingTimeline;
import androidx.media3.exoplayer.source.ShuffleOrder;
import java.util.Arrays;
import java.util.Collection;
@ -39,23 +40,26 @@ import java.util.List;
public PlaylistTimeline(
Collection<? extends MediaSourceInfoHolder> mediaSourceInfoHolders,
ShuffleOrder shuffleOrder) {
this(getTimelines(mediaSourceInfoHolders), getUids(mediaSourceInfoHolders), shuffleOrder);
}
private PlaylistTimeline(Timeline[] timelines, Object[] uids, ShuffleOrder shuffleOrder) {
super(/* isAtomic= */ false, shuffleOrder);
int childCount = mediaSourceInfoHolders.size();
int childCount = timelines.length;
this.timelines = timelines;
firstPeriodInChildIndices = new int[childCount];
firstWindowInChildIndices = new int[childCount];
timelines = new Timeline[childCount];
uids = new Object[childCount];
this.uids = uids;
childIndexByUid = new HashMap<>();
int index = 0;
int windowCount = 0;
int periodCount = 0;
for (MediaSourceInfoHolder mediaSourceInfoHolder : mediaSourceInfoHolders) {
timelines[index] = mediaSourceInfoHolder.getTimeline();
for (Timeline timeline : timelines) {
this.timelines[index] = timeline;
firstWindowInChildIndices[index] = windowCount;
firstPeriodInChildIndices[index] = periodCount;
windowCount += timelines[index].getWindowCount();
periodCount += timelines[index].getPeriodCount();
uids[index] = mediaSourceInfoHolder.getUid();
windowCount += this.timelines[index].getWindowCount();
periodCount += this.timelines[index].getPeriodCount();
childIndexByUid.put(uids[index], index++);
}
this.windowCount = windowCount;
@ -112,4 +116,40 @@ import java.util.List;
public int getPeriodCount() {
return periodCount;
}
public PlaylistTimeline copyWithPlaceholderTimeline(ShuffleOrder shuffleOrder) {
Timeline[] newTimelines = new Timeline[timelines.length];
for (int i = 0; i < timelines.length; i++) {
newTimelines[i] =
new ForwardingTimeline(timelines[i]) {
@Override
public Period getPeriod(int periodIndex, Period period, boolean setIds) {
Period superPeriod = super.getPeriod(periodIndex, period, setIds);
superPeriod.isPlaceholder = true;
return superPeriod;
}
};
}
return new PlaylistTimeline(newTimelines, uids, shuffleOrder);
}
private static Object[] getUids(
Collection<? extends MediaSourceInfoHolder> mediaSourceInfoHolders) {
Object[] uids = new Object[mediaSourceInfoHolders.size()];
int i = 0;
for (MediaSourceInfoHolder holder : mediaSourceInfoHolders) {
uids[i++] = holder.getUid();
}
return uids;
}
private static Timeline[] getTimelines(
Collection<? extends MediaSourceInfoHolder> mediaSourceInfoHolders) {
Timeline[] timelines = new Timeline[mediaSourceInfoHolders.size()];
int i = 0;
for (MediaSourceInfoHolder holder : mediaSourceInfoHolders) {
timelines[i++] = holder.getTimeline();
}
return timelines;
}
}

View File

@ -131,6 +131,7 @@ import androidx.media3.exoplayer.drm.DrmSessionManager;
import androidx.media3.exoplayer.metadata.MetadataOutput;
import androidx.media3.exoplayer.source.ClippingMediaSource;
import androidx.media3.exoplayer.source.ConcatenatingMediaSource;
import androidx.media3.exoplayer.source.ForwardingTimeline;
import androidx.media3.exoplayer.source.MaskingMediaSource;
import androidx.media3.exoplayer.source.MediaPeriod;
import androidx.media3.exoplayer.source.MediaSource;
@ -188,6 +189,7 @@ import java.util.HashSet;
import java.util.List;
import java.util.Random;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
@ -1629,6 +1631,7 @@ public final class ExoPlayerTest {
.blockUntilEnded(TIMEOUT_MS);
testRunner.assertTimelineChangeReasonsEqual(
Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED,
Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE,
Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE);
testRunner.assertPositionDiscontinuityReasonsEqual(Player.DISCONTINUITY_REASON_SEEK);
@ -1667,6 +1670,119 @@ public final class ExoPlayerTest {
mediaSource.assertReleased();
}
@Test
public void stop_withLiveStream_currentPeriodIsPlaceholder() throws TimeoutException {
ExoPlayer player = new TestExoPlayerBuilder(context).build();
FakeTimeline fakeTimeline =
new FakeTimeline(
new TimelineWindowDefinition(
/* periodCount= */ 1,
/* id= */ 0,
/* isSeekable= */ true,
/* isDynamic= */ true,
/* isLive= */ true,
/* isPlaceholder= */ false,
/* durationUs= */ 1000 * C.MICROS_PER_SECOND,
/* defaultPositionUs= */ 0,
TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US,
AdPlaybackState.NONE));
player.addMediaSources(ImmutableList.of(new FakeMediaSource(fakeTimeline)));
player.prepare();
runUntilPlaybackState(player, Player.STATE_READY);
assertThat(
player
.getCurrentTimeline()
.getPeriod(player.getCurrentPeriodIndex(), new Timeline.Period())
.isPlaceholder)
.isFalse();
player.stop();
TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player);
assertThat(
player
.getCurrentTimeline()
.getPeriod(player.getCurrentPeriodIndex(), new Timeline.Period())
.isPlaceholder)
.isTrue();
player.release();
}
@Test
public void stop_withVodStream_currentPeriodIsPlaceholder() throws TimeoutException {
ExoPlayer player = new TestExoPlayerBuilder(context).build();
player.addMediaSources(ImmutableList.of(new FakeMediaSource()));
player.prepare();
runUntilPlaybackState(player, Player.STATE_READY);
assertThat(
player
.getCurrentTimeline()
.getPeriod(player.getCurrentPeriodIndex(), new Timeline.Period())
.isPlaceholder)
.isFalse();
player.stop();
TestPlayerRunHelper.runUntilPendingCommandsAreFullyHandled(player);
assertThat(
player
.getCurrentTimeline()
.getPeriod(player.getCurrentPeriodIndex(), new Timeline.Period())
.isPlaceholder)
.isTrue();
player.release();
}
@Test
public void playbackError_withLiveStream_currentPeriodIsPlaceholder() throws TimeoutException {
ExoPlayer player = new TestExoPlayerBuilder(context).build();
FakeTimeline fakeTimeline =
new FakeTimeline(
new TimelineWindowDefinition(
/* periodCount= */ 1,
/* id= */ 0,
/* isSeekable= */ true,
/* isDynamic= */ true,
/* isLive= */ true,
/* isPlaceholder= */ false,
/* durationUs= */ 1000 * C.MICROS_PER_SECOND,
/* defaultPositionUs= */ 0,
TimelineWindowDefinition.DEFAULT_WINDOW_OFFSET_IN_FIRST_PERIOD_US,
AdPlaybackState.NONE));
FakeMediaSource fakeMediaSource =
new FakeMediaSource(fakeTimeline) {
@Override
public Timeline getInitialTimeline() {
return fakeTimeline;
}
@Override
public synchronized void prepareSourceInternal(
@Nullable TransferListener mediaTransferListener) {
super.prepareSourceInternal(mediaTransferListener);
throw new IllegalArgumentException();
}
};
player.addMediaSources(ImmutableList.of(fakeMediaSource));
player.prepare();
assertThat(
player
.getCurrentTimeline()
.getPeriod(player.getCurrentPeriodIndex(), new Timeline.Period())
.isPlaceholder)
.isFalse();
runUntilError(player);
assertThat(
player
.getCurrentTimeline()
.getPeriod(player.getCurrentPeriodIndex(), new Timeline.Period())
.isPlaceholder)
.isTrue();
player.release();
}
@Test
public void release_correctMasking() throws Exception {
int[] currentMediaItemIndex = {C.INDEX_UNSET, C.INDEX_UNSET, C.INDEX_UNSET};
@ -2001,9 +2117,11 @@ public final class ExoPlayerTest {
.start()
.blockUntilActionScheduleFinished(TIMEOUT_MS)
.blockUntilEnded(TIMEOUT_MS);
testRunner.assertTimelinesSame(placeholderTimeline, timeline);
testRunner.assertTimelinesSame(
placeholderTimeline, timeline, createPlaceholderWrapperTimeline(timeline));
testRunner.assertTimelineChangeReasonsEqual(
Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED,
Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE,
Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE);
}
@ -2018,7 +2136,7 @@ public final class ExoPlayerTest {
new IOException(), PlaybackException.ERROR_CODE_IO_UNSPECIFIED))
.waitForPlaybackState(Player.STATE_IDLE)
.prepare()
.waitForPlaybackState(Player.STATE_BUFFERING)
.waitForPlaybackState(Player.STATE_READY)
.build();
ExoPlayerTestRunner testRunner =
new ExoPlayerTestRunner.Builder(context)
@ -2031,10 +2149,13 @@ public final class ExoPlayerTest {
} catch (ExoPlaybackException e) {
// Expected exception.
}
testRunner.assertTimelinesSame(placeholderTimeline, timeline);
testRunner.assertTimelineChangeReasonsEqual(
Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED,
Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE,
Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE,
Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE);
testRunner.assertTimelinesSame(
placeholderTimeline, timeline, createPlaceholderWrapperTimeline(timeline), timeline);
}
@Test
@ -2068,11 +2189,28 @@ public final class ExoPlayerTest {
long positionWhenFullyReadyAfterReprepare = player.getCurrentPosition();
player.release();
// Ensure we don't receive further timeline updates when repreparing.
verify(mockListener)
verify(mockListener, times(4)).onTimelineChanged(any(), anyInt());
InOrder inOrder = inOrder(mockListener);
inOrder
.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());
inOrder.verify(mockListener).onPlaybackStateChanged(Player.STATE_BUFFERING);
// Source update at reset after playback exception (isPlaceholder=true)
inOrder
.verify(mockListener)
.onTimelineChanged(any(), eq(Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE));
inOrder.verify(mockListener).onPlaybackStateChanged(Player.STATE_READY);
// Source update replacing wrapper timeline of reset (isPlaceholder=false)
inOrder
.verify(mockListener)
.onTimelineChanged(any(), eq(Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE));
inOrder.verify(mockListener).onPlaybackStateChanged(Player.STATE_IDLE);
inOrder.verify(mockListener).onPlaybackStateChanged(Player.STATE_BUFFERING);
// Source update at second preparation
inOrder
.verify(mockListener)
.onTimelineChanged(any(), eq(Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE));
inOrder.verify(mockListener).onPlaybackStateChanged(Player.STATE_READY);
assertThat(positionAfterSeekHandled).isEqualTo(50);
assertThat(positionAfterReprepareHandled).isEqualTo(50);
@ -2330,10 +2468,16 @@ public final class ExoPlayerTest {
} catch (ExoPlaybackException e) {
// Expected exception.
}
testRunner.assertTimelinesSame(placeholderTimeline, timeline, placeholderTimeline, timeline);
testRunner.assertTimelinesSame(
placeholderTimeline,
timeline,
createPlaceholderWrapperTimeline(timeline),
placeholderTimeline,
timeline);
testRunner.assertTimelineChangeReasonsEqual(
Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED,
Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE,
Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE,
Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED,
Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE);
}
@ -13142,4 +13286,19 @@ public final class ExoPlayerTest {
private static ArgumentMatcher<Timeline> noUid(Timeline timeline) {
return argument -> timelinesAreSame(argument, timeline);
}
/**
* Creates a forwarding timeline that sets the {@link Timeline.Period#isPlaceholder} flag to true.
* This is what happens when the player is stopped or a playback exception is thrown.
*/
private static Timeline createPlaceholderWrapperTimeline(Timeline timeline) {
return new ForwardingTimeline(timeline) {
@Override
public Period getPeriod(int periodIndex, Period period, boolean setIds) {
Period superPeriod = super.getPeriod(periodIndex, period, setIds);
superPeriod.isPlaceholder = true;
return superPeriod;
}
};
}
}

View File

@ -0,0 +1,76 @@
/*
* Copyright 2023 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.media3.exoplayer;
import static com.google.common.truth.Truth.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import androidx.media3.common.Timeline;
import androidx.media3.exoplayer.source.ShuffleOrder;
import androidx.media3.test.utils.FakeTimeline;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.common.collect.ImmutableList;
import java.util.List;
import org.junit.Test;
import org.junit.runner.RunWith;
/** Unit test for {@link PlaylistTimeline}. */
@RunWith(AndroidJUnit4.class)
public class PlaylistTimelineTest {
@Test
public void copyWithPlaceholderTimeline_equalTimelineExceptPlaceholderFlag() {
MediaSourceInfoHolder mediaSourceInfoHolder1 = mock(MediaSourceInfoHolder.class);
MediaSourceInfoHolder mediaSourceInfoHolder2 = mock(MediaSourceInfoHolder.class);
ImmutableList<MediaSourceInfoHolder> mediaSourceInfoHolders =
ImmutableList.of(mediaSourceInfoHolder1, mediaSourceInfoHolder2);
FakeTimeline fakeTimeline1 = new FakeTimeline(2);
FakeTimeline fakeTimeline2 = new FakeTimeline(1);
when(mediaSourceInfoHolder1.getTimeline()).thenReturn(fakeTimeline1);
when(mediaSourceInfoHolder1.getUid()).thenReturn("uid1");
when(mediaSourceInfoHolder2.getTimeline()).thenReturn(fakeTimeline2);
when(mediaSourceInfoHolder2.getUid()).thenReturn("uid2");
ShuffleOrder.DefaultShuffleOrder shuffleOrder =
new ShuffleOrder.DefaultShuffleOrder(mediaSourceInfoHolders.size());
PlaylistTimeline playlistTimeline = new PlaylistTimeline(mediaSourceInfoHolders, shuffleOrder);
PlaylistTimeline playlistTimelineCopy =
playlistTimeline.copyWithPlaceholderTimeline(shuffleOrder);
assertThat(playlistTimelineCopy).isNotEqualTo(playlistTimeline);
assertThat(playlistTimelineCopy.getWindowCount()).isEqualTo(playlistTimeline.getWindowCount());
assertThat(playlistTimelineCopy.getPeriodCount()).isEqualTo(playlistTimeline.getPeriodCount());
List<Timeline> copiedChildTimelines = playlistTimelineCopy.getChildTimelines();
List<Timeline> originalChildTimelines = playlistTimeline.getChildTimelines();
for (int i = 0; i < copiedChildTimelines.size(); i++) {
Timeline childTimeline = copiedChildTimelines.get(i);
Timeline originalChildTimeline = originalChildTimelines.get(i);
for (int j = 0; j < childTimeline.getWindowCount(); j++) {
assertThat(childTimeline.getWindow(/* windowIndex= */ 0, new Timeline.Window()))
.isEqualTo(
originalChildTimeline.getWindow(/* windowIndex= */ 0, new Timeline.Window()));
Timeline.Period expectedPeriod =
originalChildTimeline.getPeriod(/* periodIndex= */ 0, new Timeline.Period());
Timeline.Period actualPeriod =
childTimeline.getPeriod(/* periodIndex= */ 0, new Timeline.Period());
assertThat(actualPeriod).isNotEqualTo(expectedPeriod);
actualPeriod.isPlaceholder = false;
assertThat(actualPeriod).isEqualTo(expectedPeriod);
}
}
}
}

View File

@ -757,7 +757,11 @@ public final class DefaultAnalyticsCollectorTest {
period0Seq0 /* ENDED */)
.inOrder();
assertThat(listener.getEvents(EVENT_TIMELINE_CHANGED))
.containsExactly(WINDOW_0 /* prepared */, WINDOW_0 /* prepared */);
.containsExactly(
WINDOW_0 /* playlist change */,
WINDOW_0 /* prepared */,
period0Seq0 /* reset after error */,
period0Seq0 /* second prepare */);
assertThat(listener.getEvents(EVENT_POSITION_DISCONTINUITY)).containsExactly(period0Seq0);
assertThat(listener.getEvents(EVENT_SEEK_STARTED)).containsExactly(period0Seq0);
assertThat(listener.getEvents(EVENT_IS_LOADING_CHANGED))