google/ExoPlayer · error · IllegalStateException
Player is accessed on the wrong thread. Current thread: '%s'
Error message
Player is accessed on the wrong thread. Current thread: '%s' Expected thread: '%s' See https://developer.android.com/guide/topics/media/issues/player-accessed-on-wrong-thread
What it means
Every public ExoPlayer method routes through verifyApplicationThread, which compares the calling thread with the application Looper's thread after the constructor has finished. ExoPlayer is not thread-safe: all access must occur on the single thread that owns the Looper passed to ExoPlayer.Builder(looper). By default (throwsWhenUsingWrongThread=true) an IllegalStateException is thrown with this message; some legacy experimental builds only logged a warning instead.
Source
Thrown at library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java:2795
throw new IllegalStateException();
}
}
private void verifyApplicationThread() {
// The constructor may be executed on a background thread. Wait with accessing the player from
// the app thread until the constructor finished executing.
constructorFinished.blockUninterruptible();
if (Thread.currentThread() != getApplicationLooper().getThread()) {
String message =
Util.formatInvariant(
"Player is accessed on the wrong thread.\n"
+ "Current thread: '%s'\n"
+ "Expected thread: '%s'\n"
+ "See https://developer.android.com/guide/topics/media/issues/"
+ "player-accessed-on-wrong-thread",
Thread.currentThread().getName(), getApplicationLooper().getThread().getName());
if (throwsWhenUsingWrongThread) {
throw new IllegalStateException(message);
}
Log.w(TAG, message, hasNotifiedFullWrongThreadWarning ? null : new IllegalStateException());
hasNotifiedFullWrongThreadWarning = true;
}
}
private void sendRendererMessage(
@C.TrackType int trackType, int messageType, @Nullable Object payload) {
for (Renderer renderer : renderers) {
if (renderer.getTrackType() == trackType) {
createMessageInternal(renderer).setType(messageType).setPayload(payload).send();
}
}
}
/**
* Initializes {@link #keepSessionIdAudioTrack} to keep an audio session ID alive. If the audio
* session ID is {@link C#AUDIO_SESSION_ID_UNSET} then a new audio session ID is generated.View on GitHub (pinned to dd430f7053)
Solutions
- Wrap every player interaction in the owner thread: run on the main thread via Activity.runOnUiThread, Handler(Looper.getMainLooper()).post, or withContext(Dispatchers.Main) in coroutines.
- If you intentionally use a dedicated playback thread, pass that thread's Looper to ExoPlayer.Builder and access the player only from it.
- Marshal third-party callbacks (analytics, DRM, ads SDKs) onto the application Looper before touching the player.
- Note Player.Listener callbacks are already invoked on the application thread — chain from them instead of spawning new threads.
Example fix
// before
scope.launch(Dispatchers.IO) {
player.setMediaItem(MediaItem.fromUri(streamUrl))
player.play()
}
// after
scope.launch {
val url = withContext(Dispatchers.IO) { fetchStreamUrl() }
player.setMediaItem(MediaItem.fromUri(url)) // Main dispatcher = player's looper
player.play()
} Defensive patterns
Strategy: validation
Validate before calling
static void onPlayerThread(Player player, Runnable action) {
Looper appLooper = player.getApplicationLooper();
if (Looper.myLooper() == appLooper) {
action.run();
} else {
new Handler(appLooper).post(action::run);
}
} Try / catch
try {
player.play();
} catch (IllegalStateException e) {
// wrong-thread access — do NOT swallow: fix the calling thread instead
throw e;
} Prevention
- Create one accessor class that owns all player calls and enforces the application Looper
- In coroutines, restrict player calls to withContext(Dispatchers.Main) or a custom Main-player dispatcher
- Marshal third-party SDK callbacks through a Handler on the app Looper before touching the player
- Enable strict lint/test review for player usage inside background dispatchers
When it happens
Trigger: Calling any player method (play, pause, setMediaItem, release, addListener, ...) from a background thread, an executor, a coroutine on Dispatchers.Default/IO, or a different activity's thread when the player was built on the main Looper.
Common situations: Launching playback work in coroutines without withContext(Dispatchers.Main); calling player.release() from a worker thread during teardown; creating the player on a custom HandlerThread but touching it from main; callbacks from SDKs that invoke on their own threads and directly poke the player.
Related errors
- Player is accessed on the wrong thread. Current thread: '%s'
- Message delivery timed out.
- Failed to load decoder native libraries.
- Missing implementation to handle COMMAND_SET_VOLUME
- Missing implementation to handle COMMAND_SET_DEVICE_VOLUME o
AI-assisted analysis of google/ExoPlayer@dd430f7053 (2026-08-14).
Data as JSON: /api/errors/33e7c35ae44bd309.
Report an issue: GitHub.