Version
Media3 1.11.0
Description
AdsMediaSource drives its AdsLoader callbacks (start(), stop(), handlePrepareComplete(), handlePrepareError()) through a Handler hardcoded to Looper.getMainLooper():
// AdsMediaSource constructor
mainHandler = new Handler(Looper.getMainLooper());
// releaseSourceInternal()
mainHandler.post(() -> adsLoader.stop(/* adsMediaSource= */ this, componentListener));
But ExoPlayer.Builder.setLooper(Looper) documents a different thread as the one the player expects to be used for all calls:
Sets the Looper that must be used for all calls to the player and that is used to call listeners on.
and ExoPlayerImpl's public API enforces exactly that looper via verifyApplicationThread(), e.g. in removeListener():
public void removeListener(Listener listener) {
verifyApplicationThread(); // throws if Thread.currentThread() != getApplicationLooper().getThread()
listeners.remove(checkNotNull(listener));
}
When an app builds its player with a non-default looper via setLooper(...) — a documented, supported way to keep player calls off the main thread — these two "the thread you're supposed to touch the player on" notions diverge. Any AdsLoader whose stop() calls player.removeListener(...) (the natural, symmetric counterpart to an addListener() in start()) will crash:
java.lang.IllegalStateException: Player is accessed on the wrong thread.
Current thread: 'main'
Expected thread: ''
Root cause
AdsMediaSource.mainHandler is unconditionally new Handler(Looper.getMainLooper()) — both in the current release and on main, unchanged across multiple major versions.
addListener() is documented and implemented to be safe from any thread (no verifyApplicationThread() call), which hides the mismatch during start().
removeListener() does call verifyApplicationThread(), so it's the first place the mismatch surfaces — but it's a symptom, not the cause: any thread-checked Player/ExoPlayer method called from code AdsMediaSource dispatches (start, stop, handlePrepareComplete, handlePrepareError) has the same exposure.
Devices that reproduce the issue
Was reproduced on a proprietary Android device.
Reproducible in the demo app?
No
Reproduction steps
- Build a player with a non-default looper: new ExoPlayer.Builder(context, ...).setLooper(customLooper).build(), where customLooper belongs to a dedicated thread, not the main thread.
- Wrap a MediaSource with HlsInterstitialsAdsLoader.AdsMediaSourceFactory (or any other AdsMediaSource-based AdsLoader) and play an HLS stream containing #EXT-X-DATERANGE interstitials.
- Once at least one ad group has started — AdsMediaSource has called adsLoader.start(), which calls player.addListener(...); this succeeds silently because addListener isn't thread-checked — stop playback, release the source, or switch media items, so AdsMediaSource.releaseSourceInternal() runs.
- Observe the crash when HlsInterstitialsAdsLoader.stop() calls player.removeListener(...).
Expected result
AdsMediaSource should dispatch to the player's actual getApplicationLooper() rather than a hardcoded Looper.getMainLooper(), so AdsLoader callbacks are safe to call any Player method from — matching the contract setLooper() establishes everywhere else in the library.
Actual result
An exception in HlsInterstitialsAdsLoader.stop()
Version
Media3 1.11.0
Description
AdsMediaSource drives its AdsLoader callbacks (start(), stop(), handlePrepareComplete(), handlePrepareError()) through a Handler hardcoded to Looper.getMainLooper():
But ExoPlayer.Builder.setLooper(Looper) documents a different thread as the one the player expects to be used for all calls:
Sets the Looper that must be used for all calls to the player and that is used to call listeners on.
and ExoPlayerImpl's public API enforces exactly that looper via verifyApplicationThread(), e.g. in removeListener():
When an app builds its player with a non-default looper via setLooper(...) — a documented, supported way to keep player calls off the main thread — these two "the thread you're supposed to touch the player on" notions diverge. Any AdsLoader whose stop() calls player.removeListener(...) (the natural, symmetric counterpart to an addListener() in start()) will crash:
java.lang.IllegalStateException: Player is accessed on the wrong thread.
Current thread: 'main'
Expected thread: ''
Root cause
AdsMediaSource.mainHandler is unconditionally new Handler(Looper.getMainLooper()) — both in the current release and on main, unchanged across multiple major versions.
addListener() is documented and implemented to be safe from any thread (no verifyApplicationThread() call), which hides the mismatch during start().
removeListener() does call verifyApplicationThread(), so it's the first place the mismatch surfaces — but it's a symptom, not the cause: any thread-checked Player/ExoPlayer method called from code AdsMediaSource dispatches (start, stop, handlePrepareComplete, handlePrepareError) has the same exposure.
Devices that reproduce the issue
Was reproduced on a proprietary Android device.
Reproducible in the demo app?
No
Reproduction steps
Expected result
AdsMediaSource should dispatch to the player's actual getApplicationLooper() rather than a hardcoded Looper.getMainLooper(), so AdsLoader callbacks are safe to call any Player method from — matching the contract setLooper() establishes everywhere else in the library.
Actual result
An exception in HlsInterstitialsAdsLoader.stop()