Fix media period mismatch during recoverable renderer error processing

If a recoverable renderer error occurred just before playing period transition(aka media item transition) then the player may enter a continuous loop of retrying to play the previous media item.

This was most easily reproduced in an audio offload scenario where init in offload mode always fails. In initializing the following media, the process would fail with recoverable error to try in non-offload mode. The player would try to recover with playing the previous media item. Most times it would skip to the next track but not always.

Issue: androidx/media#2229
PiperOrigin-RevId: 741213293
This commit is contained in:
michaelkatz 2025-03-27 11:01:59 -07:00 committed by tonihei
parent f533f55cd6
commit aea0637c95
2 changed files with 100 additions and 22 deletions

View File

@ -769,34 +769,17 @@ import java.util.concurrent.atomic.AtomicBoolean;
maybeContinueLoading(); maybeContinueLoading();
handler.sendEmptyMessage(MSG_DO_SOME_WORK); handler.sendEmptyMessage(MSG_DO_SOME_WORK);
} }
} else if (e.isRecoverable
&& (pendingRecoverableRendererError == null
|| e.errorCode == PlaybackException.ERROR_CODE_AUDIO_TRACK_OFFLOAD_INIT_FAILED
|| e.errorCode == PlaybackException.ERROR_CODE_AUDIO_TRACK_OFFLOAD_WRITE_FAILED)) {
// If pendingRecoverableRendererError != null and error was
// ERROR_CODE_AUDIO_TRACK_OFFLOAD_WRITE_FAILED then upon retry, renderer will attempt with
// offload disabled.
Log.w(TAG, "Recoverable renderer error", e);
if (pendingRecoverableRendererError != null) {
pendingRecoverableRendererError.addSuppressed(e);
e = pendingRecoverableRendererError;
} else {
pendingRecoverableRendererError = e;
}
// Given that the player is now in an unhandled exception state, the error needs to be
// recovered or the player stopped before any other message is handled.
handler.sendMessageAtFrontOfQueue(
handler.obtainMessage(MSG_ATTEMPT_RENDERER_ERROR_RECOVERY, e));
} else { } else {
if (pendingRecoverableRendererError != null) { if (pendingRecoverableRendererError != null) {
pendingRecoverableRendererError.addSuppressed(e); pendingRecoverableRendererError.addSuppressed(e);
e = pendingRecoverableRendererError; e = pendingRecoverableRendererError;
} }
Log.e(TAG, "Playback error", e);
if (e.type == ExoPlaybackException.TYPE_RENDERER if (e.type == ExoPlaybackException.TYPE_RENDERER
&& queue.getPlayingPeriod() != queue.getReadingPeriod()) { && queue.getPlayingPeriod() != queue.getReadingPeriod()) {
// We encountered a renderer error while reading ahead. Force-update the playback position // We encountered a renderer error while reading ahead. Force-update the playback position
// to the failing item to ensure the user-visible error is reported after the transition. // to the failing item to ensure correct retry or that the user-visible error is reported
// after the transition.
while (queue.getPlayingPeriod() != queue.getReadingPeriod()) { while (queue.getPlayingPeriod() != queue.getReadingPeriod()) {
queue.advancePlayingPeriod(); queue.advancePlayingPeriod();
} }
@ -812,8 +795,24 @@ import java.util.concurrent.atomic.AtomicBoolean;
/* reportDiscontinuity= */ true, /* reportDiscontinuity= */ true,
Player.DISCONTINUITY_REASON_AUTO_TRANSITION); Player.DISCONTINUITY_REASON_AUTO_TRANSITION);
} }
stopInternal(/* forceResetRenderers= */ true, /* acknowledgeStop= */ false);
playbackInfo = playbackInfo.copyWithPlaybackError(e); if (e.isRecoverable
&& (pendingRecoverableRendererError == null
|| e.errorCode == PlaybackException.ERROR_CODE_AUDIO_TRACK_OFFLOAD_INIT_FAILED
|| e.errorCode == PlaybackException.ERROR_CODE_AUDIO_TRACK_OFFLOAD_WRITE_FAILED)) {
// Given that the player is now in an unhandled exception state, the error needs to be
// recovered or the player stopped before any other message is handled.
Log.w(TAG, "Recoverable renderer error", e);
if (pendingRecoverableRendererError == null) {
pendingRecoverableRendererError = e;
}
handler.sendMessageAtFrontOfQueue(
handler.obtainMessage(MSG_ATTEMPT_RENDERER_ERROR_RECOVERY, e));
} else {
Log.e(TAG, "Playback error", e);
stopInternal(/* forceResetRenderers= */ true, /* acknowledgeStop= */ false);
playbackInfo = playbackInfo.copyWithPlaybackError(e);
}
} }
} catch (DrmSession.DrmSessionException e) { } catch (DrmSession.DrmSessionException e) {
handleIoException(e, e.errorCode); handleIoException(e, e.errorCode);

View File

@ -147,6 +147,7 @@ import androidx.media3.exoplayer.analytics.PlayerId;
import androidx.media3.exoplayer.audio.AudioRendererEventListener; import androidx.media3.exoplayer.audio.AudioRendererEventListener;
import androidx.media3.exoplayer.drm.DrmSessionEventListener; import androidx.media3.exoplayer.drm.DrmSessionEventListener;
import androidx.media3.exoplayer.drm.DrmSessionManager; import androidx.media3.exoplayer.drm.DrmSessionManager;
import androidx.media3.exoplayer.mediacodec.MediaCodecRenderer;
import androidx.media3.exoplayer.metadata.MetadataOutput; import androidx.media3.exoplayer.metadata.MetadataOutput;
import androidx.media3.exoplayer.source.ClippingMediaSource; import androidx.media3.exoplayer.source.ClippingMediaSource;
import androidx.media3.exoplayer.source.ConcatenatingMediaSource; import androidx.media3.exoplayer.source.ConcatenatingMediaSource;
@ -16748,6 +16749,84 @@ public class ExoPlayerTest {
assertThat(videoScalingSetOnSecondaryVideoRenderer.get()).isTrue(); assertThat(videoScalingSetOnSecondaryVideoRenderer.get()).isTrue();
} }
@Test
public void
play_withRecoverableErrorAfterAdvancingReadingPeriod_advancesPlayingPeriodWhileErrorHandling()
throws Exception {
Clock fakeClock = new FakeClock(/* isAutoAdvancing= */ true);
AtomicBoolean shouldRendererThrowRecoverableError = new AtomicBoolean(false);
AtomicInteger onStreamChangedCount = new AtomicInteger(0);
ExoPlayer player =
new TestExoPlayerBuilder(context)
.setClock(fakeClock)
.setRenderersFactory(
new RenderersFactory() {
@Override
public Renderer[] createRenderers(
Handler eventHandler,
VideoRendererEventListener videoRendererEventListener,
AudioRendererEventListener audioRendererEventListener,
TextOutput textRendererOutput,
MetadataOutput metadataRendererOutput) {
return new Renderer[] {
new FakeVideoRenderer(
SystemClock.DEFAULT.createHandler(
eventHandler.getLooper(), /* callback= */ null),
videoRendererEventListener) {
@Override
protected void onStreamChanged(
Format[] formats,
long startPositionUs,
long offsetUs,
MediaSource.MediaPeriodId mediaPeriodId)
throws ExoPlaybackException {
super.onStreamChanged(formats, startPositionUs, offsetUs, mediaPeriodId);
onStreamChangedCount.getAndIncrement();
}
@Override
public void render(long positionUs, long elapsedRealtimeUs)
throws ExoPlaybackException {
if (!shouldRendererThrowRecoverableError.get()) {
super.render(positionUs, elapsedRealtimeUs);
} else {
shouldRendererThrowRecoverableError.set(false);
throw createRendererException(
new MediaCodecRenderer.DecoderInitializationException(
new Format.Builder().build(),
new IllegalArgumentException(),
false,
0),
this.getFormatHolder().format,
true,
PlaybackException.ERROR_CODE_DECODER_INIT_FAILED);
}
}
}
};
}
})
.build();
player.setMediaSources(
ImmutableList.of(
new FakeMediaSource(new FakeTimeline(), ExoPlayerTestRunner.VIDEO_FORMAT),
new FakeMediaSource(new FakeTimeline(), ExoPlayerTestRunner.VIDEO_FORMAT)));
player.prepare();
// Play a bit until the reading period has advanced.
player.play();
advance(player).untilBackgroundThreadCondition(() -> onStreamChangedCount.get() == 2);
shouldRendererThrowRecoverableError.set(true);
runUntilPlaybackState(player, Player.STATE_ENDED);
player.release();
// onStreamChanged should occur thrice;
// 1 during first enable, 2 during replace stream, 3 during error recovery
assertThat(onStreamChangedCount.get()).isEqualTo(3);
assertThat(shouldRendererThrowRecoverableError.get()).isFalse();
}
// Internal methods. // Internal methods.
private void addWatchAsSystemFeature() { private void addWatchAsSystemFeature() {