diff --git a/RELEASENOTES.md b/RELEASENOTES.md index 12d512bd7a..1212b18921 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -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. diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java index 9fa44fe736..0851e32225 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java @@ -2617,17 +2617,15 @@ import java.util.concurrent.TimeoutException; 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( 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 1e7e2a4098..2c099aba80 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImplInternal.java @@ -2904,8 +2904,7 @@ import java.util.concurrent.atomic.AtomicBoolean; } 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, @@ -2914,15 +2913,14 @@ import java.util.concurrent.atomic.AtomicBoolean; 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 @@ -3219,8 +3217,7 @@ import java.util.concurrent.atomic.AtomicBoolean; } if (trySubsequentPeriods) { // Try and find a subsequent period from the seek timeline in the internal timeline. - @Nullable - Object periodUid = + int newWindowIndex = resolveSubsequentPeriod( window, period, @@ -3229,13 +3226,10 @@ import java.util.concurrent.atomic.AtomicBoolean; 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. @@ -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 - * 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. @@ -3253,11 +3247,10 @@ import java.util.concurrent.atomic.AtomicBoolean; * @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, @@ -3265,6 +3258,15 @@ import java.util.concurrent.atomic.AtomicBoolean; 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(); @@ -3278,7 +3280,9 @@ import java.util.concurrent.atomic.AtomicBoolean; } 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) { diff --git a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/MaskingMediaSource.java b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/MaskingMediaSource.java index 8383590dfc..429b6deb59 100644 --- a/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/MaskingMediaSource.java +++ b/libraries/exoplayer/src/main/java/androidx/media3/exoplayer/source/MaskingMediaSource.java @@ -200,9 +200,10 @@ public final class MaskingMediaSource extends WrappingMediaSource { : 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; @@ -235,7 +236,8 @@ public final class MaskingMediaSource extends WrappingMediaSource { } @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) { @@ -243,7 +245,7 @@ public final class MaskingMediaSource extends WrappingMediaSource { // 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) { @@ -253,6 +255,7 @@ public final class MaskingMediaSource extends WrappingMediaSource { } } maskingPeriod.overridePreparePositionUs(preparePositionOverrideUs); + return true; } /** 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 346f3b1190..aaf98fe2e0 100644 --- a/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java +++ b/libraries/exoplayer/src/test/java/androidx/media3/exoplayer/ExoPlayerTest.java @@ -182,6 +182,7 @@ import androidx.media3.test.utils.FakeMediaClockRenderer; 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; @@ -14974,6 +14975,112 @@ public class ExoPlayerTest { .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() {