Skip to content

Commit 34f9d1b

Browse files
toniheicopybara-github
authored andcommitted
Add detection for player stuck in BUFFERING state
This detection needs to happen on the application thread to capture cases where the playback thread is not responsive (e.g. stuck in a MediaCodec call). As this is only a last resort fallback timeout, we choose a really large value of 10 minutes at which we should be able to see some progress even for data sources with long timeouts and retries. As some apps may intentionally choose to wait for longer, this value is configurable on ExoPlayer. PiperOrigin-RevId: 785864050
1 parent c3e8fc5 commit 34f9d1b

File tree

7 files changed

+710
-6
lines changed

7 files changed

+710
-6
lines changed

RELEASENOTES.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@
99
and consequently restore Player's volume before and after setting it to
1010
zero.
1111
* ExoPlayer:
12+
* Add a stuck buffering detection that triggers a `StuckPlayerException`
13+
player error after 10 minutes of `STATE_BUFFERING` while trying to play
14+
and no buffering progress. This timeout is configurable in
15+
`ExoPlayer.Builder.setStuckBufferingDetectionTimeoutMs` if required.
1216
* Ensure renderers don't consume data from the next playlist item more
1317
than 10 seconds before the end of the current item.
1418
* Add getter for shuffle mode to the `ExoPlayer` interface

libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayer.java

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ final class Builder {
245245
/* package */ LivePlaybackSpeedControl livePlaybackSpeedControl;
246246
/* package */ long releaseTimeoutMs;
247247
/* package */ long detachSurfaceTimeoutMs;
248+
/* package */ int stuckBufferingDetectionTimeoutMs;
248249
/* package */ boolean pauseAtEndOfMediaItems;
249250
/* package */ boolean usePlatformDiagnostics;
250251
@Nullable /* package */ PlaybackLooperProvider playbackLooperProvider;
@@ -294,6 +295,8 @@ final class Builder {
294295
* <li>{@code maxSeekToPreviousPositionMs}: {@link C#DEFAULT_MAX_SEEK_TO_PREVIOUS_POSITION_MS}
295296
* <li>{@code releaseTimeoutMs}: {@link #DEFAULT_RELEASE_TIMEOUT_MS}
296297
* <li>{@code detachSurfaceTimeoutMs}: {@link #DEFAULT_DETACH_SURFACE_TIMEOUT_MS}
298+
* <li>{@code stuckBufferingDetectionTimeoutMs}: {@link
299+
* #DEFAULT_STUCK_BUFFERING_DETECTION_TIMEOUT_MS}
297300
* <li>{@code pauseAtEndOfMediaItems}: {@code false}
298301
* <li>{@code usePlatformDiagnostics}: {@code true}
299302
* <li>{@link Clock}: {@link Clock#DEFAULT}
@@ -458,6 +461,7 @@ private Builder(
458461
clock = Clock.DEFAULT;
459462
releaseTimeoutMs = DEFAULT_RELEASE_TIMEOUT_MS;
460463
detachSurfaceTimeoutMs = DEFAULT_DETACH_SURFACE_TIMEOUT_MS;
464+
stuckBufferingDetectionTimeoutMs = DEFAULT_STUCK_BUFFERING_DETECTION_TIMEOUT_MS;
461465
usePlatformDiagnostics = true;
462466
playerName = "";
463467
priority = C.PRIORITY_PLAYBACK;
@@ -951,6 +955,26 @@ public Builder setDetachSurfaceTimeoutMs(long detachSurfaceTimeoutMs) {
951955
return this;
952956
}
953957

958+
/**
959+
* Sets the timeout after which the player is assumed stuck buffering if it's in {@link
960+
* Player#STATE_BUFFERING} and no loading progress is made, in milliseconds.
961+
*
962+
* <p>If this timeout is triggered, the player will transition to an error state with a {@link
963+
* StuckPlayerException} using {@link StuckPlayerException#STUCK_BUFFERING_NO_PROGRESS}.
964+
*
965+
* @param stuckBufferingDetectionTimeoutMs The timeout after which the player is assumed stuck
966+
* buffering, in milliseconds.
967+
* @return This builder.
968+
* @throws IllegalStateException If {@link #build()} has already been called.
969+
*/
970+
@CanIgnoreReturnValue
971+
@UnstableApi
972+
public Builder setStuckBufferingDetectionTimeoutMs(int stuckBufferingDetectionTimeoutMs) {
973+
checkState(!buildCalled);
974+
this.stuckBufferingDetectionTimeoutMs = stuckBufferingDetectionTimeoutMs;
975+
return this;
976+
}
977+
954978
/**
955979
* Sets whether to pause playback at the end of each media item.
956980
*
@@ -1128,6 +1152,9 @@ public ExoPlayer build() {
11281152
/** The default timeout for detaching a surface from the player, in milliseconds. */
11291153
@UnstableApi long DEFAULT_DETACH_SURFACE_TIMEOUT_MS = 2_000;
11301154

1155+
/** The default timeout for detecting whether playback is stuck buffering, in milliseconds. */
1156+
@UnstableApi int DEFAULT_STUCK_BUFFERING_DETECTION_TIMEOUT_MS = 600_000;
1157+
11311158
/**
11321159
* Equivalent to {@link Player#getPlayerError()}, except the exception is guaranteed to be an
11331160
* {@link ExoPlaybackException}.

libraries/exoplayer/src/main/java/androidx/media3/exoplayer/ExoPlayerImpl.java

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@
177177
private final long detachSurfaceTimeoutMs;
178178
@Nullable private final SuitableOutputChecker suitableOutputChecker;
179179
private final BackgroundThreadStateHandler<Integer> audioSessionIdState;
180+
private final StuckPlayerDetector stuckPlayerDetector;
180181

181182
private @RepeatMode int repeatMode;
182183
private boolean shuffleModeEnabled;
@@ -449,6 +450,13 @@ public ExoPlayerImpl(ExoPlayer.Builder builder, @Nullable Player wrappingPlayer)
449450
videoSize = VideoSize.UNKNOWN;
450451
surfaceSize = Size.UNKNOWN;
451452

453+
stuckPlayerDetector =
454+
new StuckPlayerDetector(
455+
/* player= */ this,
456+
componentListener,
457+
clock,
458+
builder.stuckBufferingDetectionTimeoutMs);
459+
452460
internalPlayer.setScrubbingModeParameters(scrubbingModeParameters);
453461
internalPlayer.setAudioAttributes(audioAttributes, builder.handleAudioFocus);
454462
sendRendererMessage(TRACK_TYPE_AUDIO, MSG_SET_AUDIO_ATTRIBUTES, audioAttributes);
@@ -1040,6 +1048,7 @@ public void release() {
10401048
if (suitableOutputChecker != null) {
10411049
suitableOutputChecker.disable();
10421050
}
1051+
stuckPlayerDetector.release();
10431052
if (!internalPlayer.release()) {
10441053
// One of the renderers timed out releasing its resources.
10451054
listeners.sendEvent(
@@ -3085,7 +3094,8 @@ private final class ComponentListener
30853094
SphericalGLSurfaceView.VideoSurfaceListener,
30863095
AudioBecomingNoisyManager.EventListener,
30873096
StreamVolumeManager.Listener,
3088-
AudioOffloadListener {
3097+
AudioOffloadListener,
3098+
StuckPlayerDetector.Callback {
30893099

30903100
// VideoRendererEventListener implementation
30913101

@@ -3355,6 +3365,15 @@ public void onStreamVolumeChanged(int streamVolume, boolean streamMuted) {
33553365
public void onSleepingForOffloadChanged(boolean sleepingForOffload) {
33563366
updateWakeAndWifiLock();
33573367
}
3368+
3369+
// StuckPlayerDetector.Callback implementation.
3370+
3371+
@Override
3372+
public void onStuckPlayerDetected(StuckPlayerException exception) {
3373+
stopInternal(
3374+
ExoPlaybackException.createForUnexpected(
3375+
exception, PlaybackException.ERROR_CODE_TIMEOUT));
3376+
}
33583377
}
33593378

33603379
/** Listeners that are called on the playback thread. */
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
/*
2+
* Copyright 2025 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package androidx.media3.exoplayer;
17+
18+
import android.os.Handler;
19+
import android.os.Message;
20+
import androidx.annotation.Nullable;
21+
import androidx.media3.common.Player;
22+
import androidx.media3.common.Timeline;
23+
import androidx.media3.common.util.Clock;
24+
import androidx.media3.common.util.HandlerWrapper;
25+
import java.util.Objects;
26+
27+
/** Detection logic for stuck playbacks. */
28+
/* package */ final class StuckPlayerDetector implements Player.Listener, Handler.Callback {
29+
30+
/** Callback notified when the player appears stuck. */
31+
public interface Callback {
32+
33+
/**
34+
* Called when the player appears to be stuck.
35+
*
36+
* @param exception The {@link StuckPlayerException}.
37+
*/
38+
void onStuckPlayerDetected(StuckPlayerException exception);
39+
}
40+
41+
private static final int MSG_STUCK_BUFFERING_TIMEOUT = 1;
42+
43+
private final Player player;
44+
private final Callback callback;
45+
private final Clock clock;
46+
private final Timeline.Period period;
47+
private final HandlerWrapper handler;
48+
private final StuckBufferingDetector stuckBufferingDetector;
49+
50+
/**
51+
* Creates the stuck player detector.
52+
*
53+
* <p>Must be called on the player's {@link Player#getApplicationLooper()}.
54+
*
55+
* @param player The {@link Player} to monitor.
56+
* @param callback The {@link Callback} to notify of stuck playbacks.
57+
* @param clock The {@link Clock}.
58+
* @param stuckBufferingTimeoutMs The timeout after which the player is assumed stuck buffering if
59+
* it's buffering and no loading progress is made, in milliseconds.
60+
*/
61+
public StuckPlayerDetector(
62+
Player player, Callback callback, Clock clock, int stuckBufferingTimeoutMs) {
63+
this.player = player;
64+
this.callback = callback;
65+
this.clock = clock;
66+
this.period = new Timeline.Period();
67+
this.handler = clock.createHandler(player.getApplicationLooper(), /* callback= */ this);
68+
this.stuckBufferingDetector = new StuckBufferingDetector(stuckBufferingTimeoutMs);
69+
player.addListener(this);
70+
}
71+
72+
/** Releases the stuck player detector. */
73+
public void release() {
74+
handler.removeCallbacksAndMessages(/* token= */ null);
75+
player.removeListener(this);
76+
}
77+
78+
@Override
79+
public void onEvents(Player player, Player.Events events) {
80+
stuckBufferingDetector.update();
81+
}
82+
83+
@Override
84+
public boolean handleMessage(Message message) {
85+
switch (message.what) {
86+
case MSG_STUCK_BUFFERING_TIMEOUT:
87+
stuckBufferingDetector.update();
88+
return true;
89+
default:
90+
return false;
91+
}
92+
}
93+
94+
private final class StuckBufferingDetector {
95+
96+
private final int stuckBufferingTimeoutMs;
97+
98+
@Nullable private Object periodUid;
99+
private int adGroupIndex;
100+
private int adIndexInAdGroup;
101+
private long bufferedPositionInPeriodMs;
102+
private boolean isBuffering;
103+
private long startRealtimeMs;
104+
105+
public StuckBufferingDetector(int stuckBufferingTimeoutMs) {
106+
this.stuckBufferingTimeoutMs = stuckBufferingTimeoutMs;
107+
}
108+
109+
public void update() {
110+
if (player.getPlaybackState() != Player.STATE_BUFFERING
111+
|| !player.getPlayWhenReady()
112+
|| player.getPlaybackSuppressionReason() != Player.PLAYBACK_SUPPRESSION_REASON_NONE) {
113+
// Preconditions for stuck buffering not met. Clear any pending timeout and ignore.
114+
if (isBuffering) {
115+
handler.removeMessages(MSG_STUCK_BUFFERING_TIMEOUT);
116+
}
117+
isBuffering = false;
118+
return;
119+
}
120+
Timeline timeline = player.getCurrentTimeline();
121+
@Nullable
122+
Object periodUid =
123+
timeline.isEmpty() ? null : timeline.getUidOfPeriod(player.getCurrentPeriodIndex());
124+
int adGroupIndex = player.getCurrentAdGroupIndex();
125+
int adIndexInAdGroup = player.getCurrentAdIndexInAdGroup();
126+
long bufferedPositionInPeriodMs = player.getBufferedPosition();
127+
if (periodUid != null) {
128+
bufferedPositionInPeriodMs -=
129+
timeline.getPeriodByUid(periodUid, period).getPositionInWindowMs();
130+
}
131+
long nowRealtimeMs = clock.elapsedRealtime();
132+
if (isBuffering
133+
&& Objects.equals(periodUid, this.periodUid)
134+
&& adGroupIndex == this.adGroupIndex
135+
&& adIndexInAdGroup == this.adIndexInAdGroup
136+
&& bufferedPositionInPeriodMs == this.bufferedPositionInPeriodMs) {
137+
// Still the same state, keep current timeout.
138+
if (nowRealtimeMs - startRealtimeMs >= stuckBufferingTimeoutMs) {
139+
callback.onStuckPlayerDetected(
140+
new StuckPlayerException(StuckPlayerException.STUCK_BUFFERING_NO_PROGRESS));
141+
}
142+
} else {
143+
// Restart the timeout from the current time.
144+
isBuffering = true;
145+
startRealtimeMs = nowRealtimeMs;
146+
this.periodUid = periodUid;
147+
this.adGroupIndex = adGroupIndex;
148+
this.adIndexInAdGroup = adIndexInAdGroup;
149+
this.bufferedPositionInPeriodMs = bufferedPositionInPeriodMs;
150+
handler.removeMessages(MSG_STUCK_BUFFERING_TIMEOUT);
151+
handler.sendEmptyMessageDelayed(MSG_STUCK_BUFFERING_TIMEOUT, stuckBufferingTimeoutMs);
152+
}
153+
}
154+
}
155+
}

libraries/exoplayer/src/main/java/androidx/media3/exoplayer/StuckPlayerException.java

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import static java.lang.annotation.ElementType.TYPE_USE;
1919

2020
import androidx.annotation.IntDef;
21+
import androidx.annotation.Nullable;
2122
import androidx.media3.common.Player;
2223
import androidx.media3.common.util.UnstableApi;
2324
import java.lang.annotation.Documented;
@@ -39,17 +40,24 @@ public final class StuckPlayerException extends IllegalStateException {
3940
*/
4041
public static final int STUCK_BUFFERING_NOT_LOADING = 0;
4142

43+
/**
44+
* The player is stuck because it's in {@link Player#STATE_BUFFERING}, but no loading progress is
45+
* made and the player is also not able to become ready.
46+
*/
47+
public static final int STUCK_BUFFERING_NO_PROGRESS = 1;
48+
4249
/**
4350
* The type of stuck playback. One of:
4451
*
4552
* <ul>
4653
* <li>{@link #STUCK_BUFFERING_NOT_LOADING}
54+
* <li>{@link #STUCK_BUFFERING_NO_PROGRESS}
4755
* </ul>
4856
*/
4957
@Documented
5058
@Retention(RetentionPolicy.SOURCE)
5159
@Target(TYPE_USE)
52-
@IntDef({STUCK_BUFFERING_NOT_LOADING})
60+
@IntDef({STUCK_BUFFERING_NOT_LOADING, STUCK_BUFFERING_NO_PROGRESS})
5361
public @interface StuckType {}
5462

5563
/** The type of stuck playback. */
@@ -65,10 +73,29 @@ public StuckPlayerException(@StuckType int stuckType) {
6573
this.stuckType = stuckType;
6674
}
6775

76+
@Override
77+
public boolean equals(@Nullable Object obj) {
78+
if (this == obj) {
79+
return true;
80+
}
81+
if (obj == null || getClass() != obj.getClass()) {
82+
return false;
83+
}
84+
StuckPlayerException other = (StuckPlayerException) obj;
85+
return this.stuckType == other.stuckType;
86+
}
87+
88+
@Override
89+
public int hashCode() {
90+
return stuckType;
91+
}
92+
6893
private static String getMessage(@StuckType int stuckType) {
6994
switch (stuckType) {
7095
case STUCK_BUFFERING_NOT_LOADING:
7196
return "Player stuck buffering and not loading";
97+
case STUCK_BUFFERING_NO_PROGRESS:
98+
return "Player stuck buffering with no progress";
7299
default:
73100
throw new IllegalStateException();
74101
}

0 commit comments

Comments
 (0)