blob: 0344b09707755ee9c912b1f31c272c76cf56c96b [file] [log] [blame]
/*
* Copyright (C) 2016 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.google.android.exoplayer2;
import android.annotation.SuppressLint;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.util.Pair;
import androidx.annotation.Nullable;
import com.google.android.exoplayer2.PlayerMessage.Target;
import com.google.android.exoplayer2.analytics.AnalyticsCollector;
import com.google.android.exoplayer2.source.MediaSource;
import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId;
import com.google.android.exoplayer2.source.MediaSourceFactory;
import com.google.android.exoplayer2.source.ShuffleOrder;
import com.google.android.exoplayer2.source.TrackGroupArray;
import com.google.android.exoplayer2.trackselection.TrackSelection;
import com.google.android.exoplayer2.trackselection.TrackSelectionArray;
import com.google.android.exoplayer2.trackselection.TrackSelector;
import com.google.android.exoplayer2.trackselection.TrackSelectorResult;
import com.google.android.exoplayer2.upstream.BandwidthMeter;
import com.google.android.exoplayer2.util.Assertions;
import com.google.android.exoplayer2.util.Clock;
import com.google.android.exoplayer2.util.Log;
import com.google.android.exoplayer2.util.Util;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.TimeoutException;
/**
* An {@link ExoPlayer} implementation. Instances can be obtained from {@link ExoPlayer.Builder}.
*/
/* package */ final class ExoPlayerImpl extends BasePlayer implements ExoPlayer {
private static final String TAG = "ExoPlayerImpl";
/**
* This empty track selector result can only be used for {@link PlaybackInfo#trackSelectorResult}
* when the player does not have any track selection made (such as when player is reset, or when
* player seeks to an unprepared period). It will not be used as result of any {@link
* TrackSelector#selectTracks(RendererCapabilities[], TrackGroupArray, MediaPeriodId, Timeline)}
* operation.
*/
/* package */ final TrackSelectorResult emptyTrackSelectorResult;
private final Renderer[] renderers;
private final TrackSelector trackSelector;
private final Handler eventHandler;
private final ExoPlayerImplInternal internalPlayer;
private final Handler internalPlayerHandler;
private final CopyOnWriteArrayList<ListenerHolder> listeners;
private final Timeline.Period period;
private final ArrayDeque<Runnable> pendingListenerNotifications;
private final List<MediaSourceList.MediaSourceHolder> mediaSourceHolders;
private final boolean useLazyPreparation;
private final MediaSourceFactory mediaSourceFactory;
@RepeatMode private int repeatMode;
private boolean shuffleModeEnabled;
private int pendingOperationAcks;
private boolean hasPendingDiscontinuity;
@DiscontinuityReason private int pendingDiscontinuityReason;
@PlayWhenReadyChangeReason private int pendingPlayWhenReadyChangeReason;
private boolean foregroundMode;
private int pendingSetPlaybackSpeedAcks;
private float playbackSpeed;
private SeekParameters seekParameters;
private ShuffleOrder shuffleOrder;
private boolean pauseAtEndOfMediaItems;
// Playback information when there is no pending seek/set source operation.
private PlaybackInfo playbackInfo;
// Playback information when there is a pending seek/set source operation.
private int maskingWindowIndex;
private int maskingPeriodIndex;
private long maskingWindowPositionMs;
/**
* Constructs an instance. Must be called from a thread that has an associated {@link Looper}.
*
* @param renderers The {@link Renderer}s.
* @param trackSelector The {@link TrackSelector}.
* @param mediaSourceFactory The {@link MediaSourceFactory}.
* @param loadControl The {@link LoadControl}.
* @param bandwidthMeter The {@link BandwidthMeter}.
* @param analyticsCollector The {@link AnalyticsCollector}.
* @param useLazyPreparation Whether playlist items are prepared lazily. If false, all manifest
* loads and other initial preparation steps happen immediately. If true, these initial
* preparations are triggered only when the player starts buffering the media.
* @param clock The {@link Clock}.
* @param looper The {@link Looper} which must be used for all calls to the player and which is
* used to call listeners on.
*/
@SuppressLint("HandlerLeak")
public ExoPlayerImpl(
Renderer[] renderers,
TrackSelector trackSelector,
MediaSourceFactory mediaSourceFactory,
LoadControl loadControl,
BandwidthMeter bandwidthMeter,
@Nullable AnalyticsCollector analyticsCollector,
boolean useLazyPreparation,
Clock clock,
Looper looper) {
Log.i(TAG, "Init " + Integer.toHexString(System.identityHashCode(this)) + " ["
+ ExoPlayerLibraryInfo.VERSION_SLASHY + "] [" + Util.DEVICE_DEBUG_INFO + "]");
Assertions.checkState(renderers.length > 0);
this.renderers = Assertions.checkNotNull(renderers);
this.trackSelector = Assertions.checkNotNull(trackSelector);
this.mediaSourceFactory = mediaSourceFactory;
this.useLazyPreparation = useLazyPreparation;
repeatMode = Player.REPEAT_MODE_OFF;
listeners = new CopyOnWriteArrayList<>();
mediaSourceHolders = new ArrayList<>();
shuffleOrder = new ShuffleOrder.DefaultShuffleOrder(/* length= */ 0);
emptyTrackSelectorResult =
new TrackSelectorResult(
new RendererConfiguration[renderers.length],
new TrackSelection[renderers.length],
null);
period = new Timeline.Period();
playbackSpeed = Player.DEFAULT_PLAYBACK_SPEED;
seekParameters = SeekParameters.DEFAULT;
maskingWindowIndex = C.INDEX_UNSET;
eventHandler =
new Handler(looper) {
@Override
public void handleMessage(Message msg) {
ExoPlayerImpl.this.handleEvent(msg);
}
};
playbackInfo = PlaybackInfo.createDummy(emptyTrackSelectorResult);
pendingListenerNotifications = new ArrayDeque<>();
if (analyticsCollector != null) {
analyticsCollector.setPlayer(this);
}
internalPlayer =
new ExoPlayerImplInternal(
renderers,
trackSelector,
emptyTrackSelectorResult,
loadControl,
bandwidthMeter,
repeatMode,
shuffleModeEnabled,
analyticsCollector,
eventHandler,
clock);
internalPlayerHandler = new Handler(internalPlayer.getPlaybackLooper());
}
/**
* Set a limit on the time a call to {@link #release()} can spend. If a call to {@link #release()}
* takes more than {@code timeoutMs} milliseconds to complete, the player will raise an error via
* {@link Player.EventListener#onPlayerError}.
*
* <p>This method is experimental, and will be renamed or removed in a future release. It should
* only be called before the player is used.
*
* @param timeoutMs The time limit in milliseconds, or 0 for no limit.
*/
public void experimental_setReleaseTimeoutMs(long timeoutMs) {
internalPlayer.experimental_setReleaseTimeoutMs(timeoutMs);
}
/**
* Configures the player to throw when it detects it's stuck buffering.
*
* <p>This method is experimental, and will be renamed or removed in a future release. It should
* only be called before the player is used.
*/
public void experimental_throwWhenStuckBuffering() {
internalPlayer.experimental_throwWhenStuckBuffering();
}
@Override
@Nullable
public AudioComponent getAudioComponent() {
return null;
}
@Override
@Nullable
public VideoComponent getVideoComponent() {
return null;
}
@Override
@Nullable
public TextComponent getTextComponent() {
return null;
}
@Override
@Nullable
public MetadataComponent getMetadataComponent() {
return null;
}
@Override
@Nullable
public DeviceComponent getDeviceComponent() {
return null;
}
@Override
public Looper getPlaybackLooper() {
return internalPlayer.getPlaybackLooper();
}
@Override
public Looper getApplicationLooper() {
return eventHandler.getLooper();
}
@Override
public void addListener(Player.EventListener listener) {
listeners.addIfAbsent(new ListenerHolder(listener));
}
@Override
public void removeListener(Player.EventListener listener) {
for (ListenerHolder listenerHolder : listeners) {
if (listenerHolder.listener.equals(listener)) {
listenerHolder.release();
listeners.remove(listenerHolder);
}
}
}
@Override
@State
public int getPlaybackState() {
return playbackInfo.playbackState;
}
@Override
@PlaybackSuppressionReason
public int getPlaybackSuppressionReason() {
return playbackInfo.playbackSuppressionReason;
}
@Deprecated
@Override
@Nullable
public ExoPlaybackException getPlaybackError() {
return getPlayerError();
}
@Override
@Nullable
public ExoPlaybackException getPlayerError() {
return playbackInfo.playbackError;
}
/** @deprecated Use {@link #prepare()} instead. */
@Deprecated
@Override
public void retry() {
prepare();
}
@Override
public void prepare() {
if (playbackInfo.playbackState != Player.STATE_IDLE) {
return;
}
PlaybackInfo playbackInfo =
getResetPlaybackInfo(
/* clearPlaylist= */ false,
/* resetError= */ true,
/* playbackState= */ this.playbackInfo.timeline.isEmpty()
? Player.STATE_ENDED
: Player.STATE_BUFFERING);
// Trigger internal prepare first before updating the playback info and notifying external
// listeners to ensure that new operations issued in the listener notifications reach the
// player after this prepare. The internal player can't change the playback info immediately
// because it uses a callback.
pendingOperationAcks++;
internalPlayer.prepare();
updatePlaybackInfo(
playbackInfo,
/* positionDiscontinuity= */ false,
/* ignored */ DISCONTINUITY_REASON_INTERNAL,
/* ignored */ TIMELINE_CHANGE_REASON_SOURCE_UPDATE,
/* ignored */ PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST,
/* seekProcessed= */ false);
}
/**
* @deprecated Use {@link #setMediaSource(MediaSource)} and {@link ExoPlayer#prepare()} instead.
*/
@Deprecated
@Override
public void prepare(MediaSource mediaSource) {
setMediaSource(mediaSource);
prepare();
}
/**
* @deprecated Use {@link #setMediaSource(MediaSource, boolean)} and {@link ExoPlayer#prepare()}
* instead.
*/
@Deprecated
@Override
public void prepare(MediaSource mediaSource, boolean resetPosition, boolean resetState) {
setMediaSource(mediaSource, resetPosition);
prepare();
}
@Override
public void setMediaItems(
List<MediaItem> mediaItems, int startWindowIndex, long startPositionMs) {
setMediaSources(createMediaSources(mediaItems), startWindowIndex, startPositionMs);
}
@Override
public void setMediaSource(MediaSource mediaSource) {
setMediaSources(Collections.singletonList(mediaSource));
}
@Override
public void setMediaSource(MediaSource mediaSource, long startPositionMs) {
setMediaSources(
Collections.singletonList(mediaSource), /* startWindowIndex= */ 0, startPositionMs);
}
@Override
public void setMediaSource(MediaSource mediaSource, boolean resetPosition) {
setMediaSources(Collections.singletonList(mediaSource), resetPosition);
}
@Override
public void setMediaSources(List<MediaSource> mediaSources) {
setMediaSources(mediaSources, /* resetPosition= */ true);
}
@Override
public void setMediaSources(List<MediaSource> mediaSources, boolean resetPosition) {
setMediaSourcesInternal(
mediaSources,
/* startWindowIndex= */ C.INDEX_UNSET,
/* startPositionMs= */ C.TIME_UNSET,
/* resetToDefaultPosition= */ resetPosition);
}
@Override
public void setMediaSources(
List<MediaSource> mediaSources, int startWindowIndex, long startPositionMs) {
setMediaSourcesInternal(
mediaSources, startWindowIndex, startPositionMs, /* resetToDefaultPosition= */ false);
}
@Override
public void addMediaItems(List<MediaItem> mediaItems) {
addMediaItems(/* index= */ mediaSourceHolders.size(), mediaItems);
}
@Override
public void addMediaItems(int index, List<MediaItem> mediaItems) {
addMediaSources(index, createMediaSources(mediaItems));
}
@Override
public void addMediaSource(MediaSource mediaSource) {
addMediaSources(Collections.singletonList(mediaSource));
}
@Override
public void addMediaSource(int index, MediaSource mediaSource) {
addMediaSources(index, Collections.singletonList(mediaSource));
}
@Override
public void addMediaSources(List<MediaSource> mediaSources) {
addMediaSources(/* index= */ mediaSourceHolders.size(), mediaSources);
}
@Override
public void addMediaSources(int index, List<MediaSource> mediaSources) {
Assertions.checkArgument(index >= 0);
for (int i = 0; i < mediaSources.size(); i++) {
Assertions.checkArgument(mediaSources.get(i) != null);
}
int currentWindowIndex = getCurrentWindowIndex();
long currentPositionMs = getCurrentPosition();
Timeline oldTimeline = getCurrentTimeline();
pendingOperationAcks++;
List<MediaSourceList.MediaSourceHolder> holders = addMediaSourceHolders(index, mediaSources);
PlaybackInfo playbackInfo =
maskTimelineAndWindowIndex(currentWindowIndex, currentPositionMs, oldTimeline);
internalPlayer.addMediaSources(index, holders, shuffleOrder);
updatePlaybackInfo(
playbackInfo,
/* positionDiscontinuity= */ false,
/* ignored */ DISCONTINUITY_REASON_INTERNAL,
/* timelineChangeReason= */ TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED,
/* ignored */ PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST,
/* seekProcessed= */ false);
}
@Override
public void removeMediaItems(int fromIndex, int toIndex) {
Assertions.checkArgument(toIndex > fromIndex);
removeMediaItemsInternal(fromIndex, toIndex);
}
@Override
public void moveMediaItems(int fromIndex, int toIndex, int newFromIndex) {
Assertions.checkArgument(
fromIndex >= 0
&& fromIndex <= toIndex
&& toIndex <= mediaSourceHolders.size()
&& newFromIndex >= 0);
int currentWindowIndex = getCurrentWindowIndex();
long currentPositionMs = getCurrentPosition();
Timeline oldTimeline = getCurrentTimeline();
pendingOperationAcks++;
newFromIndex = Math.min(newFromIndex, mediaSourceHolders.size() - (toIndex - fromIndex));
MediaSourceList.moveMediaSourceHolders(mediaSourceHolders, fromIndex, toIndex, newFromIndex);
PlaybackInfo playbackInfo =
maskTimelineAndWindowIndex(currentWindowIndex, currentPositionMs, oldTimeline);
internalPlayer.moveMediaSources(fromIndex, toIndex, newFromIndex, shuffleOrder);
updatePlaybackInfo(
playbackInfo,
/* positionDiscontinuity= */ false,
/* ignored */ DISCONTINUITY_REASON_INTERNAL,
/* timelineChangeReason= */ TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED,
/* ignored */ PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST,
/* seekProcessed= */ false);
}
@Override
public void clearMediaItems() {
if (mediaSourceHolders.isEmpty()) {
return;
}
removeMediaItemsInternal(/* fromIndex= */ 0, /* toIndex= */ mediaSourceHolders.size());
}
@Override
public void setShuffleOrder(ShuffleOrder shuffleOrder) {
PlaybackInfo playbackInfo = maskTimeline();
maskWithCurrentPosition();
pendingOperationAcks++;
this.shuffleOrder = shuffleOrder;
internalPlayer.setShuffleOrder(shuffleOrder);
updatePlaybackInfo(
playbackInfo,
/* positionDiscontinuity= */ false,
/* ignored */ DISCONTINUITY_REASON_INTERNAL,
/* timelineChangeReason= */ TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED,
/* ignored */ PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST,
/* seekProcessed= */ false);
}
@Override
public void setPlayWhenReady(boolean playWhenReady) {
setPlayWhenReady(
playWhenReady,
PLAYBACK_SUPPRESSION_REASON_NONE,
PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST);
}
@Override
public void setPauseAtEndOfMediaItems(boolean pauseAtEndOfMediaItems) {
if (this.pauseAtEndOfMediaItems == pauseAtEndOfMediaItems) {
return;
}
this.pauseAtEndOfMediaItems = pauseAtEndOfMediaItems;
internalPlayer.setPauseAtEndOfWindow(pauseAtEndOfMediaItems);
}
@Override
public boolean getPauseAtEndOfMediaItems() {
return pauseAtEndOfMediaItems;
}
public void setPlayWhenReady(
boolean playWhenReady,
@PlaybackSuppressionReason int playbackSuppressionReason,
@PlayWhenReadyChangeReason int playWhenReadyChangeReason) {
if (playbackInfo.playWhenReady == playWhenReady
&& playbackInfo.playbackSuppressionReason == playbackSuppressionReason) {
return;
}
maskWithCurrentPosition();
pendingOperationAcks++;
PlaybackInfo playbackInfo =
this.playbackInfo.copyWithPlayWhenReady(playWhenReady, playbackSuppressionReason);
internalPlayer.setPlayWhenReady(playWhenReady, playbackSuppressionReason);
updatePlaybackInfo(
playbackInfo,
/* positionDiscontinuity= */ false,
/* ignored */ DISCONTINUITY_REASON_INTERNAL,
/* ignored */ TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED,
playWhenReadyChangeReason,
/* seekProcessed= */ false);
}
@Override
public boolean getPlayWhenReady() {
return playbackInfo.playWhenReady;
}
@Override
public void setRepeatMode(@RepeatMode int repeatMode) {
if (this.repeatMode != repeatMode) {
this.repeatMode = repeatMode;
internalPlayer.setRepeatMode(repeatMode);
notifyListeners(listener -> listener.onRepeatModeChanged(repeatMode));
}
}
@Override
public @RepeatMode int getRepeatMode() {
return repeatMode;
}
@Override
public void setShuffleModeEnabled(boolean shuffleModeEnabled) {
if (this.shuffleModeEnabled != shuffleModeEnabled) {
this.shuffleModeEnabled = shuffleModeEnabled;
internalPlayer.setShuffleModeEnabled(shuffleModeEnabled);
notifyListeners(listener -> listener.onShuffleModeEnabledChanged(shuffleModeEnabled));
}
}
@Override
public boolean getShuffleModeEnabled() {
return shuffleModeEnabled;
}
@Override
public boolean isLoading() {
return playbackInfo.isLoading;
}
@Override
public void seekTo(int windowIndex, long positionMs) {
Timeline timeline = playbackInfo.timeline;
if (windowIndex < 0 || (!timeline.isEmpty() && windowIndex >= timeline.getWindowCount())) {
throw new IllegalSeekPositionException(timeline, windowIndex, positionMs);
}
pendingOperationAcks++;
if (isPlayingAd()) {
// TODO: Investigate adding support for seeking during ads. This is complicated to do in
// general because the midroll ad preceding the seek destination must be played before the
// content position can be played, if a different ad is playing at the moment.
Log.w(TAG, "seekTo ignored because an ad is playing");
eventHandler
.obtainMessage(
ExoPlayerImplInternal.MSG_PLAYBACK_INFO_CHANGED,
/* operationAcks */ 1,
/* positionDiscontinuityReason */ C.INDEX_UNSET,
playbackInfo)
.sendToTarget();
return;
}
maskWindowIndexAndPositionForSeek(timeline, windowIndex, positionMs);
@Player.State
int newPlaybackState =
getPlaybackState() == Player.STATE_IDLE ? Player.STATE_IDLE : Player.STATE_BUFFERING;
PlaybackInfo playbackInfo = this.playbackInfo.copyWithPlaybackState(newPlaybackState);
internalPlayer.seekTo(timeline, windowIndex, C.msToUs(positionMs));
updatePlaybackInfo(
playbackInfo,
/* positionDiscontinuity= */ true,
/* positionDiscontinuityReason= */ DISCONTINUITY_REASON_SEEK,
/* ignored */ TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED,
/* ignored */ PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST,
/* seekProcessed= */ true);
}
/** @deprecated Use {@link #setPlaybackSpeed(float)} instead. */
@SuppressWarnings("deprecation")
@Deprecated
@Override
public void setPlaybackParameters(@Nullable PlaybackParameters playbackParameters) {
setPlaybackSpeed(
playbackParameters != null ? playbackParameters.speed : Player.DEFAULT_PLAYBACK_SPEED);
}
/** @deprecated Use {@link #getPlaybackSpeed()} instead. */
@SuppressWarnings("deprecation")
@Deprecated
@Override
public PlaybackParameters getPlaybackParameters() {
return new PlaybackParameters(playbackSpeed);
}
@SuppressWarnings("deprecation")
@Override
public void setPlaybackSpeed(float playbackSpeed) {
Assertions.checkState(playbackSpeed > 0);
if (this.playbackSpeed == playbackSpeed) {
return;
}
pendingSetPlaybackSpeedAcks++;
this.playbackSpeed = playbackSpeed;
PlaybackParameters playbackParameters = new PlaybackParameters(playbackSpeed);
internalPlayer.setPlaybackSpeed(playbackSpeed);
notifyListeners(
listener -> {
listener.onPlaybackParametersChanged(playbackParameters);
listener.onPlaybackSpeedChanged(playbackSpeed);
});
}
@Override
public float getPlaybackSpeed() {
return playbackSpeed;
}
@Override
public void setSeekParameters(@Nullable SeekParameters seekParameters) {
if (seekParameters == null) {
seekParameters = SeekParameters.DEFAULT;
}
if (!this.seekParameters.equals(seekParameters)) {
this.seekParameters = seekParameters;
internalPlayer.setSeekParameters(seekParameters);
}
}
@Override
public SeekParameters getSeekParameters() {
return seekParameters;
}
@Override
public void setForegroundMode(boolean foregroundMode) {
if (this.foregroundMode != foregroundMode) {
this.foregroundMode = foregroundMode;
internalPlayer.setForegroundMode(foregroundMode);
}
}
@Override
public void stop(boolean reset) {
PlaybackInfo playbackInfo =
getResetPlaybackInfo(
/* clearPlaylist= */ reset,
/* resetError= */ reset,
/* playbackState= */ Player.STATE_IDLE);
// Trigger internal stop first before updating the playback info and notifying external
// listeners to ensure that new operations issued in the listener notifications reach the
// player after this stop. The internal player can't change the playback info immediately
// because it uses a callback.
pendingOperationAcks++;
internalPlayer.stop(reset);
updatePlaybackInfo(
playbackInfo,
/* positionDiscontinuity= */ false,
/* ignored */ DISCONTINUITY_REASON_INTERNAL,
TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED,
/* ignored */ PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST,
/* seekProcessed= */ false);
}
@Override
public void release() {
Log.i(TAG, "Release " + Integer.toHexString(System.identityHashCode(this)) + " ["
+ ExoPlayerLibraryInfo.VERSION_SLASHY + "] [" + Util.DEVICE_DEBUG_INFO + "] ["
+ ExoPlayerLibraryInfo.registeredModules() + "]");
if (!internalPlayer.release()) {
notifyListeners(
listener ->
listener.onPlayerError(
ExoPlaybackException.createForUnexpected(
new RuntimeException(new TimeoutException("Player release timed out.")))));
}
eventHandler.removeCallbacksAndMessages(null);
playbackInfo =
getResetPlaybackInfo(
/* clearPlaylist= */ false,
/* resetError= */ false,
/* playbackState= */ Player.STATE_IDLE);
}
@Override
public PlayerMessage createMessage(Target target) {
return new PlayerMessage(
internalPlayer,
target,
playbackInfo.timeline,
getCurrentWindowIndex(),
internalPlayerHandler);
}
@Override
public int getCurrentPeriodIndex() {
if (shouldMaskPosition()) {
return maskingPeriodIndex;
} else {
return playbackInfo.timeline.getIndexOfPeriod(playbackInfo.periodId.periodUid);
}
}
@Override
public int getCurrentWindowIndex() {
int currentWindowIndex = getCurrentWindowIndexInternal();
return currentWindowIndex == C.INDEX_UNSET ? 0 : currentWindowIndex;
}
@Override
public long getDuration() {
if (isPlayingAd()) {
MediaPeriodId periodId = playbackInfo.periodId;
playbackInfo.timeline.getPeriodByUid(periodId.periodUid, period);
long adDurationUs = period.getAdDurationUs(periodId.adGroupIndex, periodId.adIndexInAdGroup);
return C.usToMs(adDurationUs);
}
return getContentDuration();
}
@Override
public long getCurrentPosition() {
if (shouldMaskPosition()) {
return maskingWindowPositionMs;
} else if (playbackInfo.periodId.isAd()) {
return C.usToMs(playbackInfo.positionUs);
} else {
return periodPositionUsToWindowPositionMs(playbackInfo.periodId, playbackInfo.positionUs);
}
}
@Override
public long getBufferedPosition() {
if (isPlayingAd()) {
return playbackInfo.loadingMediaPeriodId.equals(playbackInfo.periodId)
? C.usToMs(playbackInfo.bufferedPositionUs)
: getDuration();
}
return getContentBufferedPosition();
}
@Override
public long getTotalBufferedDuration() {
return C.usToMs(playbackInfo.totalBufferedDurationUs);
}
@Override
public boolean isPlayingAd() {
return !shouldMaskPosition() && playbackInfo.periodId.isAd();
}
@Override
public int getCurrentAdGroupIndex() {
return isPlayingAd() ? playbackInfo.periodId.adGroupIndex : C.INDEX_UNSET;
}
@Override
public int getCurrentAdIndexInAdGroup() {
return isPlayingAd() ? playbackInfo.periodId.adIndexInAdGroup : C.INDEX_UNSET;
}
@Override
public long getContentPosition() {
if (isPlayingAd()) {
playbackInfo.timeline.getPeriodByUid(playbackInfo.periodId.periodUid, period);
return playbackInfo.requestedContentPositionUs == C.TIME_UNSET
? playbackInfo.timeline.getWindow(getCurrentWindowIndex(), window).getDefaultPositionMs()
: period.getPositionInWindowMs() + C.usToMs(playbackInfo.requestedContentPositionUs);
} else {
return getCurrentPosition();
}
}
@Override
public long getContentBufferedPosition() {
if (shouldMaskPosition()) {
return maskingWindowPositionMs;
}
if (playbackInfo.loadingMediaPeriodId.windowSequenceNumber
!= playbackInfo.periodId.windowSequenceNumber) {
return playbackInfo.timeline.getWindow(getCurrentWindowIndex(), window).getDurationMs();
}
long contentBufferedPositionUs = playbackInfo.bufferedPositionUs;
if (playbackInfo.loadingMediaPeriodId.isAd()) {
Timeline.Period loadingPeriod =
playbackInfo.timeline.getPeriodByUid(playbackInfo.loadingMediaPeriodId.periodUid, period);
contentBufferedPositionUs =
loadingPeriod.getAdGroupTimeUs(playbackInfo.loadingMediaPeriodId.adGroupIndex);
if (contentBufferedPositionUs == C.TIME_END_OF_SOURCE) {
contentBufferedPositionUs = loadingPeriod.durationUs;
}
}
return periodPositionUsToWindowPositionMs(
playbackInfo.loadingMediaPeriodId, contentBufferedPositionUs);
}
@Override
public int getRendererCount() {
return renderers.length;
}
@Override
public int getRendererType(int index) {
return renderers[index].getTrackType();
}
@Override
public TrackGroupArray getCurrentTrackGroups() {
return playbackInfo.trackGroups;
}
@Override
public TrackSelectionArray getCurrentTrackSelections() {
return playbackInfo.trackSelectorResult.selections;
}
@Override
public Timeline getCurrentTimeline() {
return playbackInfo.timeline;
}
// Not private so it can be called from an inner class without going through a thunk method.
/* package */ void handleEvent(Message msg) {
switch (msg.what) {
case ExoPlayerImplInternal.MSG_PLAYBACK_INFO_CHANGED:
handlePlaybackInfo((ExoPlayerImplInternal.PlaybackInfoUpdate) msg.obj);
break;
case ExoPlayerImplInternal.MSG_PLAYBACK_SPEED_CHANGED:
handlePlaybackSpeed((Float) msg.obj, /* operationAck= */ msg.arg1 != 0);
break;
default:
throw new IllegalStateException();
}
}
private int getCurrentWindowIndexInternal() {
if (shouldMaskPosition()) {
return maskingWindowIndex;
} else {
return playbackInfo.timeline.getPeriodByUid(playbackInfo.periodId.periodUid, period)
.windowIndex;
}
}
private List<MediaSource> createMediaSources(List<MediaItem> mediaItems) {
List<MediaSource> mediaSources = new ArrayList<>();
for (int i = 0; i < mediaItems.size(); i++) {
mediaSources.add(mediaSourceFactory.createMediaSource(mediaItems.get(i)));
}
return mediaSources;
}
@SuppressWarnings("deprecation")
private void handlePlaybackSpeed(float playbackSpeed, boolean operationAck) {
if (operationAck) {
pendingSetPlaybackSpeedAcks--;
}
if (pendingSetPlaybackSpeedAcks == 0) {
if (this.playbackSpeed != playbackSpeed) {
this.playbackSpeed = playbackSpeed;
notifyListeners(
listener -> {
listener.onPlaybackParametersChanged(new PlaybackParameters(playbackSpeed));
listener.onPlaybackSpeedChanged(playbackSpeed);
});
}
}
}
private void handlePlaybackInfo(ExoPlayerImplInternal.PlaybackInfoUpdate playbackInfoUpdate) {
pendingOperationAcks -= playbackInfoUpdate.operationAcks;
if (playbackInfoUpdate.positionDiscontinuity) {
hasPendingDiscontinuity = true;
pendingDiscontinuityReason = playbackInfoUpdate.discontinuityReason;
}
if (playbackInfoUpdate.hasPlayWhenReadyChangeReason) {
pendingPlayWhenReadyChangeReason = playbackInfoUpdate.playWhenReadyChangeReason;
}
if (pendingOperationAcks == 0) {
if (!this.playbackInfo.timeline.isEmpty()
&& playbackInfoUpdate.playbackInfo.timeline.isEmpty()) {
// Update the masking variables, which are used when the timeline becomes empty.
resetMaskingPosition();
}
boolean positionDiscontinuity = hasPendingDiscontinuity;
hasPendingDiscontinuity = false;
updatePlaybackInfo(
playbackInfoUpdate.playbackInfo,
positionDiscontinuity,
pendingDiscontinuityReason,
TIMELINE_CHANGE_REASON_SOURCE_UPDATE,
pendingPlayWhenReadyChangeReason,
/* seekProcessed= */ false);
}
}
private PlaybackInfo getResetPlaybackInfo(
boolean clearPlaylist, boolean resetError, @Player.State int playbackState) {
if (clearPlaylist) {
// Reset list of media source holders which are used for creating the masking timeline.
removeMediaSourceHolders(
/* fromIndex= */ 0, /* toIndexExclusive= */ mediaSourceHolders.size());
resetMaskingPosition();
} else {
maskWithCurrentPosition();
}
Timeline timeline = playbackInfo.timeline;
MediaPeriodId mediaPeriodId = playbackInfo.periodId;
long requestedContentPositionUs = playbackInfo.requestedContentPositionUs;
long positionUs = playbackInfo.positionUs;
if (clearPlaylist) {
timeline = Timeline.EMPTY;
mediaPeriodId = PlaybackInfo.getDummyPeriodForEmptyTimeline();
requestedContentPositionUs = C.TIME_UNSET;
positionUs = 0;
}
return new PlaybackInfo(
timeline,
mediaPeriodId,
requestedContentPositionUs,
playbackState,
resetError ? null : playbackInfo.playbackError,
/* isLoading= */ false,
clearPlaylist ? TrackGroupArray.EMPTY : playbackInfo.trackGroups,
clearPlaylist ? emptyTrackSelectorResult : playbackInfo.trackSelectorResult,
mediaPeriodId,
playbackInfo.playWhenReady,
playbackInfo.playbackSuppressionReason,
positionUs,
/* totalBufferedDurationUs= */ 0,
positionUs);
}
private void updatePlaybackInfo(
PlaybackInfo playbackInfo,
boolean positionDiscontinuity,
@DiscontinuityReason int positionDiscontinuityReason,
@TimelineChangeReason int timelineChangeReason,
@PlayWhenReadyChangeReason int playWhenReadyChangeReason,
boolean seekProcessed) {
// Assign playback info immediately such that all getters return the right values.
PlaybackInfo previousPlaybackInfo = this.playbackInfo;
this.playbackInfo = playbackInfo;
notifyListeners(
new PlaybackInfoUpdate(
playbackInfo,
previousPlaybackInfo,
listeners,
trackSelector,
positionDiscontinuity,
positionDiscontinuityReason,
timelineChangeReason,
playWhenReadyChangeReason,
seekProcessed));
}
private void setMediaSourcesInternal(
List<MediaSource> mediaSources,
int startWindowIndex,
long startPositionMs,
boolean resetToDefaultPosition) {
for (int i = 0; i < mediaSources.size(); i++) {
Assertions.checkArgument(mediaSources.get(i) != null);
}
int currentWindowIndex = getCurrentWindowIndexInternal();
long currentPositionMs = getCurrentPosition();
pendingOperationAcks++;
if (!mediaSourceHolders.isEmpty()) {
removeMediaSourceHolders(
/* fromIndex= */ 0, /* toIndexExclusive= */ mediaSourceHolders.size());
}
List<MediaSourceList.MediaSourceHolder> holders =
addMediaSourceHolders(/* index= */ 0, mediaSources);
PlaybackInfo playbackInfo = maskTimeline();
Timeline timeline = playbackInfo.timeline;
if (!timeline.isEmpty() && startWindowIndex >= timeline.getWindowCount()) {
throw new IllegalSeekPositionException(timeline, startWindowIndex, startPositionMs);
}
// Evaluate the actual start position.
if (resetToDefaultPosition) {
startWindowIndex = timeline.getFirstWindowIndex(shuffleModeEnabled);
startPositionMs = C.TIME_UNSET;
} else if (startWindowIndex == C.INDEX_UNSET) {
startWindowIndex = currentWindowIndex;
startPositionMs = currentPositionMs;
}
maskWindowIndexAndPositionForSeek(
timeline, startWindowIndex == C.INDEX_UNSET ? 0 : startWindowIndex, startPositionMs);
// Mask the playback state.
int maskingPlaybackState = playbackInfo.playbackState;
if (startWindowIndex != C.INDEX_UNSET && playbackInfo.playbackState != STATE_IDLE) {
// Position reset to startWindowIndex (results in pending initial seek).
if (timeline.isEmpty() || startWindowIndex >= timeline.getWindowCount()) {
// Setting an empty timeline or invalid seek transitions to ended.
maskingPlaybackState = STATE_ENDED;
} else {
maskingPlaybackState = STATE_BUFFERING;
}
}
playbackInfo = playbackInfo.copyWithPlaybackState(maskingPlaybackState);
internalPlayer.setMediaSources(
holders, startWindowIndex, C.msToUs(startPositionMs), shuffleOrder);
updatePlaybackInfo(
playbackInfo,
/* positionDiscontinuity= */ false,
/* ignored */ Player.DISCONTINUITY_REASON_INTERNAL,
/* timelineChangeReason= */ TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED,
/* ignored */ PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST,
/* seekProcessed= */ false);
}
private List<MediaSourceList.MediaSourceHolder> addMediaSourceHolders(
int index, List<MediaSource> mediaSources) {
List<MediaSourceList.MediaSourceHolder> holders = new ArrayList<>();
for (int i = 0; i < mediaSources.size(); i++) {
MediaSourceList.MediaSourceHolder holder =
new MediaSourceList.MediaSourceHolder(mediaSources.get(i), useLazyPreparation);
holders.add(holder);
mediaSourceHolders.add(i + index, holder);
}
shuffleOrder =
shuffleOrder.cloneAndInsert(
/* insertionIndex= */ index, /* insertionCount= */ holders.size());
return holders;
}
private void removeMediaItemsInternal(int fromIndex, int toIndex) {
Assertions.checkArgument(
fromIndex >= 0 && toIndex >= fromIndex && toIndex <= mediaSourceHolders.size());
int currentWindowIndex = getCurrentWindowIndex();
long currentPositionMs = getCurrentPosition();
Timeline oldTimeline = getCurrentTimeline();
int currentMediaSourceCount = mediaSourceHolders.size();
pendingOperationAcks++;
removeMediaSourceHolders(fromIndex, /* toIndexExclusive= */ toIndex);
PlaybackInfo playbackInfo =
maskTimelineAndWindowIndex(currentWindowIndex, currentPositionMs, oldTimeline);
// Player transitions to STATE_ENDED if the current index is part of the removed tail.
final boolean transitionsToEnded =
playbackInfo.playbackState != STATE_IDLE
&& playbackInfo.playbackState != STATE_ENDED
&& fromIndex < toIndex
&& toIndex == currentMediaSourceCount
&& currentWindowIndex >= playbackInfo.timeline.getWindowCount();
if (transitionsToEnded) {
playbackInfo = playbackInfo.copyWithPlaybackState(STATE_ENDED);
}
internalPlayer.removeMediaSources(fromIndex, toIndex, shuffleOrder);
updatePlaybackInfo(
playbackInfo,
/* positionDiscontinuity= */ false,
/* ignored */ Player.DISCONTINUITY_REASON_INTERNAL,
/* timelineChangeReason= */ TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED,
/* ignored */ PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST,
/* seekProcessed= */ false);
}
private List<MediaSourceList.MediaSourceHolder> removeMediaSourceHolders(
int fromIndex, int toIndexExclusive) {
List<MediaSourceList.MediaSourceHolder> removed = new ArrayList<>();
for (int i = toIndexExclusive - 1; i >= fromIndex; i--) {
removed.add(mediaSourceHolders.remove(i));
}
shuffleOrder = shuffleOrder.cloneAndRemove(fromIndex, toIndexExclusive);
return removed;
}
private PlaybackInfo maskTimeline() {
return playbackInfo.copyWithTimeline(
mediaSourceHolders.isEmpty()
? Timeline.EMPTY
: new MediaSourceList.PlaylistTimeline(mediaSourceHolders, shuffleOrder));
}
private PlaybackInfo maskTimelineAndWindowIndex(
int currentWindowIndex, long currentPositionMs, Timeline oldTimeline) {
PlaybackInfo playbackInfo = maskTimeline();
Timeline maskingTimeline = playbackInfo.timeline;
if (oldTimeline.isEmpty()) {
// The index is the default index or was set by a seek in the empty old timeline.
maskingWindowIndex = currentWindowIndex;
if (!maskingTimeline.isEmpty() && currentWindowIndex >= maskingTimeline.getWindowCount()) {
// The seek is not valid in the new timeline.
maskWithDefaultPosition(maskingTimeline);
}
return playbackInfo;
}
@Nullable
Pair<Object, Long> periodPosition =
oldTimeline.getPeriodPosition(
window,
period,
currentWindowIndex,
C.msToUs(currentPositionMs),
/* defaultPositionProjectionUs= */ 0);
Object periodUid = Util.castNonNull(periodPosition).first;
if (maskingTimeline.getIndexOfPeriod(periodUid) != C.INDEX_UNSET) {
// Get the window index of the current period that exists in the new timeline also.
maskingWindowIndex = maskingTimeline.getPeriodByUid(periodUid, period).windowIndex;
maskingPeriodIndex = maskingTimeline.getIndexOfPeriod(periodUid);
maskingWindowPositionMs = currentPositionMs;
} else {
// Period uid not found in new timeline. Try to get subsequent period.
@Nullable
Object nextPeriodUid =
ExoPlayerImplInternal.resolveSubsequentPeriod(
window,
period,
repeatMode,
shuffleModeEnabled,
periodUid,
oldTimeline,
maskingTimeline);
if (nextPeriodUid != null) {
// Set masking to the default position of the window of the subsequent period.
maskingWindowIndex = maskingTimeline.getPeriodByUid(nextPeriodUid, period).windowIndex;
maskingPeriodIndex = maskingTimeline.getWindow(maskingWindowIndex, window).firstPeriodIndex;
maskingWindowPositionMs = window.getDefaultPositionMs();
} else {
// Reset if no subsequent period is found.
maskWithDefaultPosition(maskingTimeline);
}
}
return playbackInfo;
}
private void maskWindowIndexAndPositionForSeek(
Timeline timeline, int windowIndex, long positionMs) {
maskingWindowIndex = windowIndex;
if (timeline.isEmpty()) {
maskingWindowPositionMs = positionMs == C.TIME_UNSET ? 0 : positionMs;
maskingPeriodIndex = 0;
} else if (windowIndex >= timeline.getWindowCount()) {
// An initial seek now proves to be invalid in the actual timeline.
maskWithDefaultPosition(timeline);
} else {
long windowPositionUs =
positionMs == C.TIME_UNSET
? timeline.getWindow(windowIndex, window).getDefaultPositionUs()
: C.msToUs(positionMs);
Pair<Object, Long> periodUidAndPosition =
timeline.getPeriodPosition(window, period, windowIndex, windowPositionUs);
maskingWindowPositionMs = C.usToMs(windowPositionUs);
maskingPeriodIndex = timeline.getIndexOfPeriod(periodUidAndPosition.first);
}
}
private void maskWithCurrentPosition() {
maskingWindowIndex = getCurrentWindowIndexInternal();
maskingPeriodIndex = getCurrentPeriodIndex();
maskingWindowPositionMs = getCurrentPosition();
}
private void maskWithDefaultPosition(Timeline timeline) {
if (timeline.isEmpty()) {
resetMaskingPosition();
return;
}
maskingWindowIndex = timeline.getFirstWindowIndex(shuffleModeEnabled);
timeline.getWindow(maskingWindowIndex, window);
maskingWindowPositionMs = window.getDefaultPositionMs();
maskingPeriodIndex = window.firstPeriodIndex;
}
private void resetMaskingPosition() {
maskingWindowIndex = C.INDEX_UNSET;
maskingWindowPositionMs = 0;
maskingPeriodIndex = 0;
}
private void notifyListeners(ListenerInvocation listenerInvocation) {
CopyOnWriteArrayList<ListenerHolder> listenerSnapshot = new CopyOnWriteArrayList<>(listeners);
notifyListeners(() -> invokeAll(listenerSnapshot, listenerInvocation));
}
private void notifyListeners(Runnable listenerNotificationRunnable) {
boolean isRunningRecursiveListenerNotification = !pendingListenerNotifications.isEmpty();
pendingListenerNotifications.addLast(listenerNotificationRunnable);
if (isRunningRecursiveListenerNotification) {
return;
}
while (!pendingListenerNotifications.isEmpty()) {
pendingListenerNotifications.peekFirst().run();
pendingListenerNotifications.removeFirst();
}
}
private long periodPositionUsToWindowPositionMs(MediaPeriodId periodId, long positionUs) {
long positionMs = C.usToMs(positionUs);
playbackInfo.timeline.getPeriodByUid(periodId.periodUid, period);
positionMs += period.getPositionInWindowMs();
return positionMs;
}
private boolean shouldMaskPosition() {
return playbackInfo.timeline.isEmpty() || pendingOperationAcks > 0;
}
private static final class PlaybackInfoUpdate implements Runnable {
private final PlaybackInfo playbackInfo;
private final CopyOnWriteArrayList<ListenerHolder> listenerSnapshot;
private final TrackSelector trackSelector;
private final boolean positionDiscontinuity;
@DiscontinuityReason private final int positionDiscontinuityReason;
@TimelineChangeReason private final int timelineChangeReason;
@PlayWhenReadyChangeReason private final int playWhenReadyChangeReason;
private final boolean seekProcessed;
private final boolean playbackStateChanged;
private final boolean playbackErrorChanged;
private final boolean timelineChanged;
private final boolean isLoadingChanged;
private final boolean trackSelectorResultChanged;
private final boolean isPlayingChanged;
private final boolean playWhenReadyChanged;
private final boolean playbackSuppressionReasonChanged;
public PlaybackInfoUpdate(
PlaybackInfo playbackInfo,
PlaybackInfo previousPlaybackInfo,
CopyOnWriteArrayList<ListenerHolder> listeners,
TrackSelector trackSelector,
boolean positionDiscontinuity,
@DiscontinuityReason int positionDiscontinuityReason,
@TimelineChangeReason int timelineChangeReason,
@PlayWhenReadyChangeReason int playWhenReadyChangeReason,
boolean seekProcessed) {
this.playbackInfo = playbackInfo;
this.listenerSnapshot = new CopyOnWriteArrayList<>(listeners);
this.trackSelector = trackSelector;
this.positionDiscontinuity = positionDiscontinuity;
this.positionDiscontinuityReason = positionDiscontinuityReason;
this.timelineChangeReason = timelineChangeReason;
this.playWhenReadyChangeReason = playWhenReadyChangeReason;
this.seekProcessed = seekProcessed;
playbackStateChanged = previousPlaybackInfo.playbackState != playbackInfo.playbackState;
playbackErrorChanged =
previousPlaybackInfo.playbackError != playbackInfo.playbackError
&& playbackInfo.playbackError != null;
isLoadingChanged = previousPlaybackInfo.isLoading != playbackInfo.isLoading;
timelineChanged = !previousPlaybackInfo.timeline.equals(playbackInfo.timeline);
trackSelectorResultChanged =
previousPlaybackInfo.trackSelectorResult != playbackInfo.trackSelectorResult;
playWhenReadyChanged = previousPlaybackInfo.playWhenReady != playbackInfo.playWhenReady;
playbackSuppressionReasonChanged =
previousPlaybackInfo.playbackSuppressionReason != playbackInfo.playbackSuppressionReason;
isPlayingChanged = isPlaying(previousPlaybackInfo) != isPlaying(playbackInfo);
}
@SuppressWarnings("deprecation")
@Override
public void run() {
if (timelineChanged) {
invokeAll(
listenerSnapshot,
listener -> listener.onTimelineChanged(playbackInfo.timeline, timelineChangeReason));
}
if (positionDiscontinuity) {
invokeAll(
listenerSnapshot,
listener -> listener.onPositionDiscontinuity(positionDiscontinuityReason));
}
if (playbackErrorChanged) {
invokeAll(listenerSnapshot, listener -> listener.onPlayerError(playbackInfo.playbackError));
}
if (trackSelectorResultChanged) {
trackSelector.onSelectionActivated(playbackInfo.trackSelectorResult.info);
invokeAll(
listenerSnapshot,
listener ->
listener.onTracksChanged(
playbackInfo.trackGroups, playbackInfo.trackSelectorResult.selections));
}
if (isLoadingChanged) {
invokeAll(
listenerSnapshot, listener -> listener.onIsLoadingChanged(playbackInfo.isLoading));
}
if (playbackStateChanged || playWhenReadyChanged) {
invokeAll(
listenerSnapshot,
listener ->
listener.onPlayerStateChanged(
playbackInfo.playWhenReady, playbackInfo.playbackState));
}
if (playbackStateChanged) {
invokeAll(
listenerSnapshot,
listener -> listener.onPlaybackStateChanged(playbackInfo.playbackState));
}
if (playWhenReadyChanged) {
invokeAll(
listenerSnapshot,
listener ->
listener.onPlayWhenReadyChanged(
playbackInfo.playWhenReady, playWhenReadyChangeReason));
}
if (playbackSuppressionReasonChanged) {
invokeAll(
listenerSnapshot,
listener ->
listener.onPlaybackSuppressionReasonChanged(
playbackInfo.playbackSuppressionReason));
}
if (isPlayingChanged) {
invokeAll(
listenerSnapshot, listener -> listener.onIsPlayingChanged(isPlaying(playbackInfo)));
}
if (seekProcessed) {
invokeAll(listenerSnapshot, EventListener::onSeekProcessed);
}
}
private static boolean isPlaying(PlaybackInfo playbackInfo) {
return playbackInfo.playbackState == Player.STATE_READY
&& playbackInfo.playWhenReady
&& playbackInfo.playbackSuppressionReason == PLAYBACK_SUPPRESSION_REASON_NONE;
}
}
private static void invokeAll(
CopyOnWriteArrayList<ListenerHolder> listeners, ListenerInvocation listenerInvocation) {
for (ListenerHolder listenerHolder : listeners) {
listenerHolder.invoke(listenerInvocation);
}
}
}