diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java index 30d8206dfbb..770011c586c 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java @@ -1492,9 +1492,17 @@ private void resetInternal( 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, diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/MediaSourceList.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/MediaSourceList.java index 21cd5ceec47..cda970a7c59 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/MediaSourceList.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/MediaSourceList.java @@ -365,6 +365,11 @@ public Timeline createTimeline() { return new PlaylistTimeline(mediaSourceHolders, shuffleOrder); } + /** Returns the shuffle order */ + public ShuffleOrder getShuffleOrder() { + return shuffleOrder; + } + // Internal methods. private void enableMediaSource(MediaSourceHolder mediaSourceHolder) { diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/PlaylistTimeline.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/PlaylistTimeline.java index 5b1398d49e2..75a7ec2d14f 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/PlaylistTimeline.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/PlaylistTimeline.java @@ -18,6 +18,7 @@ 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 @@ public PlaylistTimeline( Collection 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 @@ public int getWindowCount() { 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 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 mediaSourceInfoHolders) { + Timeline[] timelines = new Timeline[mediaSourceInfoHolders.size()]; + int i = 0; + for (MediaSourceInfoHolder holder : mediaSourceInfoHolders) { + timelines[i++] = holder.getTimeline(); + } + return timelines; + } } diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java index 6e537649121..f00997e1609 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java @@ -133,6 +133,7 @@ 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; @@ -190,6 +191,7 @@ 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; @@ -1563,6 +1565,7 @@ public void run(ExoPlayer player) { .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); @@ -1601,6 +1604,119 @@ public void stop_releasesMediaSource() throws Exception { 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}; @@ -1935,9 +2051,11 @@ public void stopAndSeekAfterStopDoesNotResetTimeline() throws Exception { .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); } @@ -1952,7 +2070,7 @@ public void reprepareAfterPlaybackError() throws Exception { 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) @@ -1965,10 +2083,13 @@ public void reprepareAfterPlaybackError() throws Exception { } 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 @@ -2002,11 +2123,28 @@ public void seekAndReprepareAfterPlaybackError_keepsSeekPositionAndTimeline() th 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); @@ -2264,10 +2402,16 @@ public void playbackErrorTwiceStillKeepsTimeline() throws Exception { } 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); } @@ -13273,4 +13417,19 @@ public PlaybackParameters getPlaybackParameters() { private static ArgumentMatcher 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; + } + }; + } } diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/PlaylistTimelineTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/PlaylistTimelineTest.java new file mode 100644 index 00000000000..d04a397f020 --- /dev/null +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/PlaylistTimelineTest.java @@ -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 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 copiedChildTimelines = playlistTimelineCopy.getChildTimelines(); + List 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); + } + } + } +} diff --git a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/analytics/DefaultAnalyticsCollectorTest.java b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/analytics/DefaultAnalyticsCollectorTest.java index 2701b4b625e..50c35052990 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/analytics/DefaultAnalyticsCollectorTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/analytics/DefaultAnalyticsCollectorTest.java @@ -745,7 +745,11 @@ public void reprepareAfterError() throws Exception { 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))