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

  1. 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.
  2. If you intentionally use a dedicated playback thread, pass that thread's Looper to ExoPlayer.Builder and access the player only from it.
  3. Marshal third-party callbacks (analytics, DRM, ads SDKs) onto the application Looper before touching the player.
  4. 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

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


AI-assisted analysis of google/ExoPlayer@dd430f7053 (2026-08-14). Data as JSON: /api/errors/33e7c35ae44bd309. Report an issue: GitHub.