diff --git a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java index 020b71c692..12cc108019 100644 --- a/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java +++ b/extensions/cast/src/main/java/com/google/android/exoplayer2/ext/cast/CastPlayer.java @@ -35,6 +35,7 @@ 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.util.Assertions; +import com.google.android.exoplayer2.util.ListenerSet; import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.gms.cast.CastStatusCodes; @@ -50,12 +51,8 @@ import com.google.android.gms.cast.framework.media.RemoteMediaClient; import com.google.android.gms.cast.framework.media.RemoteMediaClient.MediaChannelResult; import com.google.android.gms.common.api.PendingResult; import com.google.android.gms.common.api.ResultCallback; -import java.util.ArrayDeque; -import java.util.ArrayList; import java.util.Collections; -import java.util.Iterator; import java.util.List; -import java.util.concurrent.CopyOnWriteArrayList; import org.checkerframework.checker.nullness.qual.RequiresNonNull; /** @@ -99,9 +96,7 @@ public final class CastPlayer extends BasePlayer { private final SeekResultCallback seekResultCallback; // Listeners and notification. - private final CopyOnWriteArrayList listeners; - private final ArrayList notificationsBatch; - private final ArrayDeque ongoingNotificationsTasks; + private final ListenerSet listeners; @Nullable private SessionAvailabilityListener sessionAvailabilityListener; // Internal state. @@ -140,9 +135,7 @@ public final class CastPlayer extends BasePlayer { period = new Timeline.Period(); statusListener = new StatusListener(); seekResultCallback = new SeekResultCallback(); - listeners = new CopyOnWriteArrayList<>(); - notificationsBatch = new ArrayList<>(); - ongoingNotificationsTasks = new ArrayDeque<>(); + listeners = new ListenerSet<>(); playWhenReady = new StateHolder<>(false); repeatMode = new StateHolder<>(REPEAT_MODE_OFF); @@ -295,18 +288,12 @@ public final class CastPlayer extends BasePlayer { @Override public void addListener(EventListener listener) { - Assertions.checkNotNull(listener); - listeners.addIfAbsent(new ListenerHolder(listener)); + listeners.add(listener); } @Override public void removeListener(EventListener listener) { - for (ListenerHolder listenerHolder : listeners) { - if (listenerHolder.listener.equals(listener)) { - listenerHolder.release(); - listeners.remove(listenerHolder); - } - } + listeners.remove(listener); } @Override @@ -418,7 +405,7 @@ public final class CastPlayer extends BasePlayer { // the local state will be updated to reflect the state reported by the Cast SDK. setPlayerStateAndNotifyIfChanged( playWhenReady, PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST, playbackState); - flushNotifications(); + listeners.flushEvents(); PendingResult pendingResult = playWhenReady ? remoteMediaClient.play() : remoteMediaClient.pause(); this.playWhenReady.pendingResultCallback = @@ -427,7 +414,7 @@ public final class CastPlayer extends BasePlayer { public void onResult(MediaChannelResult mediaChannelResult) { if (remoteMediaClient != null) { updatePlayerStateAndNotifyIfChanged(this); - flushNotifications(); + listeners.flushEvents(); } } }; @@ -458,13 +445,11 @@ public final class CastPlayer extends BasePlayer { pendingSeekCount++; pendingSeekWindowIndex = windowIndex; pendingSeekPositionMs = positionMs; - notificationsBatch.add( - new ListenerNotificationTask( - listener -> listener.onPositionDiscontinuity(DISCONTINUITY_REASON_SEEK))); + listeners.queueEvent(listener -> listener.onPositionDiscontinuity(DISCONTINUITY_REASON_SEEK)); } else if (pendingSeekCount == 0) { - notificationsBatch.add(new ListenerNotificationTask(EventListener::onSeekProcessed)); + listeners.queueEvent(EventListener::onSeekProcessed); } - flushNotifications(); + listeners.flushEvents(); } @Override @@ -528,7 +513,7 @@ public final class CastPlayer extends BasePlayer { // operation to be perceived as synchronous by the user. When the operation reports a result, // the local state will be updated to reflect the state reported by the Cast SDK. setRepeatModeAndNotifyIfChanged(repeatMode); - flushNotifications(); + listeners.flushEvents(); PendingResult pendingResult = remoteMediaClient.queueSetRepeatMode(getCastRepeatMode(repeatMode), /* jsonObject= */ null); this.repeatMode.pendingResultCallback = @@ -537,7 +522,7 @@ public final class CastPlayer extends BasePlayer { public void onResult(MediaChannelResult mediaChannelResult) { if (remoteMediaClient != null) { updateRepeatModeAndNotifyIfChanged(this); - flushNotifications(); + listeners.flushEvents(); } } }; @@ -662,8 +647,7 @@ public final class CastPlayer extends BasePlayer { updatePlayerStateAndNotifyIfChanged(/* resultCallback= */ null); boolean isPlaying = playbackState == Player.STATE_READY && playWhenReady.value; if (wasPlaying != isPlaying) { - notificationsBatch.add( - new ListenerNotificationTask(listener -> listener.onIsPlayingChanged(isPlaying))); + listeners.queueEvent(listener -> listener.onIsPlayingChanged(isPlaying)); } updateRepeatModeAndNotifyIfChanged(/* resultCallback= */ null); updateTimelineAndNotifyIfChanged(); @@ -679,17 +663,14 @@ public final class CastPlayer extends BasePlayer { } if (this.currentWindowIndex != currentWindowIndex && pendingSeekCount == 0) { this.currentWindowIndex = currentWindowIndex; - notificationsBatch.add( - new ListenerNotificationTask( - listener -> - listener.onPositionDiscontinuity(DISCONTINUITY_REASON_PERIOD_TRANSITION))); + listeners.queueEvent( + listener -> listener.onPositionDiscontinuity(DISCONTINUITY_REASON_PERIOD_TRANSITION)); } if (updateTracksAndSelectionsAndNotifyIfChanged()) { - notificationsBatch.add( - new ListenerNotificationTask( - listener -> listener.onTracksChanged(currentTrackGroups, currentTrackSelection))); + listeners.queueEvent( + listener -> listener.onTracksChanged(currentTrackGroups, currentTrackSelection)); } - flushNotifications(); + listeners.flushEvents(); } /** @@ -728,11 +709,10 @@ public final class CastPlayer extends BasePlayer { if (updateTimeline()) { // TODO: Differentiate TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED and // TIMELINE_CHANGE_REASON_SOURCE_UPDATE [see internal: b/65152553]. - notificationsBatch.add( - new ListenerNotificationTask( - listener -> - listener.onTimelineChanged( - currentTimeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE))); + listeners.queueEvent( + listener -> + listener.onTimelineChanged( + currentTimeline, Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE)); } } @@ -851,8 +831,7 @@ public final class CastPlayer extends BasePlayer { private void setRepeatModeAndNotifyIfChanged(@Player.RepeatMode int repeatMode) { if (this.repeatMode.value != repeatMode) { this.repeatMode.value = repeatMode; - notificationsBatch.add( - new ListenerNotificationTask(listener -> listener.onRepeatModeChanged(repeatMode))); + listeners.queueEvent(listener -> listener.onRepeatModeChanged(repeatMode)); } } @@ -866,17 +845,16 @@ public final class CastPlayer extends BasePlayer { if (playWhenReadyChanged || playbackStateChanged) { this.playbackState = playbackState; this.playWhenReady.value = playWhenReady; - notificationsBatch.add( - new ListenerNotificationTask( - listener -> { - listener.onPlayerStateChanged(playWhenReady, playbackState); - if (playbackStateChanged) { - listener.onPlaybackStateChanged(playbackState); - } - if (playWhenReadyChanged) { - listener.onPlayWhenReadyChanged(playWhenReady, playWhenReadyChangeReason); - } - })); + listeners.queueEvent( + listener -> { + listener.onPlayerStateChanged(playWhenReady, playbackState); + if (playbackStateChanged) { + listener.onPlaybackStateChanged(playbackState); + } + if (playWhenReadyChanged) { + listener.onPlayWhenReadyChanged(playWhenReady, playWhenReadyChangeReason); + } + }); } } @@ -984,20 +962,6 @@ public final class CastPlayer extends BasePlayer { } } - private void flushNotifications() { - boolean recursiveNotification = !ongoingNotificationsTasks.isEmpty(); - ongoingNotificationsTasks.addAll(notificationsBatch); - notificationsBatch.clear(); - if (recursiveNotification) { - // This will be handled once the current notification task is finished. - return; - } - while (!ongoingNotificationsTasks.isEmpty()) { - ongoingNotificationsTasks.peekFirst().execute(); - ongoingNotificationsTasks.removeFirst(); - } - } - private MediaQueueItem[] toMediaQueueItems(List mediaItems) { MediaQueueItem[] mediaQueueItems = new MediaQueueItem[mediaItems.size()]; for (int i = 0; i < mediaItems.size(); i++) { @@ -1108,8 +1072,7 @@ public final class CastPlayer extends BasePlayer { if (--pendingSeekCount == 0) { pendingSeekWindowIndex = C.INDEX_UNSET; pendingSeekPositionMs = C.TIME_UNSET; - notificationsBatch.add(new ListenerNotificationTask(EventListener::onSeekProcessed)); - flushNotifications(); + listeners.sendEvent(EventListener::onSeekProcessed); } } } @@ -1149,21 +1112,4 @@ public final class CastPlayer extends BasePlayer { return pendingResultCallback == resultCallback; } } - - private final class ListenerNotificationTask { - - private final Iterator listenersSnapshot; - private final ListenerInvocation listenerInvocation; - - private ListenerNotificationTask(ListenerInvocation listenerInvocation) { - this.listenersSnapshot = listeners.iterator(); - this.listenerInvocation = listenerInvocation; - } - - public void execute() { - while (listenersSnapshot.hasNext()) { - listenersSnapshot.next().invoke(listenerInvocation); - } - } - } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/BasePlayer.java b/library/core/src/main/java/com/google/android/exoplayer2/BasePlayer.java index 4f89925121..150a13e288 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/BasePlayer.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/BasePlayer.java @@ -249,58 +249,4 @@ public abstract class BasePlayer implements Player { @RepeatMode int repeatMode = getRepeatMode(); return repeatMode == REPEAT_MODE_ONE ? REPEAT_MODE_OFF : repeatMode; } - - /** Holds a listener reference. */ - protected static final class ListenerHolder { - - /** - * The listener on which {link #invoke} will execute {@link ListenerInvocation listener - * invocations}. - */ - public final Player.EventListener listener; - - private boolean released; - - public ListenerHolder(Player.EventListener listener) { - this.listener = listener; - } - - /** Prevents any further {@link ListenerInvocation} to be executed on {@link #listener}. */ - public void release() { - released = true; - } - - /** - * Executes the given {@link ListenerInvocation} on {@link #listener}. Does nothing if {@link - * #release} has been called on this instance. - */ - public void invoke(ListenerInvocation listenerInvocation) { - if (!released) { - listenerInvocation.invokeListener(listener); - } - } - - @Override - public boolean equals(@Nullable Object other) { - if (this == other) { - return true; - } - if (other == null || getClass() != other.getClass()) { - return false; - } - return listener.equals(((ListenerHolder) other).listener); - } - - @Override - public int hashCode() { - return listener.hashCode(); - } - } - - /** Parameterized invocation of a {@link Player.EventListener} method. */ - protected interface ListenerInvocation { - - /** Executes the invocation on the given {@link Player.EventListener}. */ - void invokeListener(Player.EventListener listener); - } } diff --git a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java index 4fdd80b561..dcc28f974e 100644 --- a/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java +++ b/library/core/src/main/java/com/google/android/exoplayer2/ExoPlayerImpl.java @@ -42,14 +42,13 @@ 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.ListenerSet; import com.google.android.exoplayer2.util.Log; import com.google.android.exoplayer2.util.Util; import com.google.common.collect.ImmutableList; -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; /** @@ -74,9 +73,8 @@ import java.util.concurrent.TimeoutException; private final ExoPlayerImplInternal.PlaybackInfoUpdateListener playbackInfoUpdateListener; private final ExoPlayerImplInternal internalPlayer; private final Handler internalPlayerHandler; - private final CopyOnWriteArrayList listeners; + private final ListenerSet listeners; private final Timeline.Period period; - private final ArrayDeque pendingListenerNotifications; private final List mediaSourceHolderSnapshots; private final boolean useLazyPreparation; private final MediaSourceFactory mediaSourceFactory; @@ -152,7 +150,7 @@ import java.util.concurrent.TimeoutException; this.pauseAtEndOfMediaItems = pauseAtEndOfMediaItems; this.applicationLooper = applicationLooper; repeatMode = Player.REPEAT_MODE_OFF; - listeners = new CopyOnWriteArrayList<>(); + listeners = new ListenerSet<>(); mediaSourceHolderSnapshots = new ArrayList<>(); shuffleOrder = new ShuffleOrder.DefaultShuffleOrder(/* length= */ 0); emptyTrackSelectorResult = @@ -167,7 +165,6 @@ import java.util.concurrent.TimeoutException; playbackInfoUpdate -> playbackInfoUpdateHandler.post(() -> handlePlaybackInfo(playbackInfoUpdate)); playbackInfo = PlaybackInfo.createDummy(emptyTrackSelectorResult); - pendingListenerNotifications = new ArrayDeque<>(); if (analyticsCollector != null) { analyticsCollector.setPlayer(this); addListener(analyticsCollector); @@ -255,18 +252,12 @@ import java.util.concurrent.TimeoutException; @Override public void addListener(Player.EventListener listener) { - Assertions.checkNotNull(listener); - listeners.addIfAbsent(new ListenerHolder(listener)); + listeners.add(listener); } @Override public void removeListener(Player.EventListener listener) { - for (ListenerHolder listenerHolder : listeners) { - if (listenerHolder.listener.equals(listener)) { - listenerHolder.release(); - listeners.remove(listenerHolder); - } - } + listeners.remove(listener); } @Override @@ -560,7 +551,7 @@ import java.util.concurrent.TimeoutException; if (this.repeatMode != repeatMode) { this.repeatMode = repeatMode; internalPlayer.setRepeatMode(repeatMode); - notifyListeners(listener -> listener.onRepeatModeChanged(repeatMode)); + listeners.sendEvent(listener -> listener.onRepeatModeChanged(repeatMode)); } } @@ -574,7 +565,7 @@ import java.util.concurrent.TimeoutException; if (this.shuffleModeEnabled != shuffleModeEnabled) { this.shuffleModeEnabled = shuffleModeEnabled; internalPlayer.setShuffleModeEnabled(shuffleModeEnabled); - notifyListeners(listener -> listener.onShuffleModeEnabledChanged(shuffleModeEnabled)); + listeners.sendEvent(listener -> listener.onShuffleModeEnabledChanged(shuffleModeEnabled)); } } @@ -723,7 +714,7 @@ import java.util.concurrent.TimeoutException; + ExoPlayerLibraryInfo.VERSION_SLASHY + "] [" + Util.DEVICE_DEBUG_INFO + "] [" + ExoPlayerLibraryInfo.registeredModules() + "]"); if (!internalPlayer.release()) { - notifyListeners( + listeners.sendEvent( listener -> listener.onPlayerError( ExoPlaybackException.createForTimeout( @@ -942,6 +933,8 @@ import java.util.concurrent.TimeoutException; } } + // Calling deprecated listeners. + @SuppressWarnings("deprecation") private void updatePlaybackInfo( PlaybackInfo playbackInfo, boolean positionDiscontinuity, @@ -949,39 +942,106 @@ import java.util.concurrent.TimeoutException; @TimelineChangeReason int timelineChangeReason, @PlayWhenReadyChangeReason int playWhenReadyChangeReason, boolean seekProcessed) { - // Assign playback info immediately such that all getters return the right values. + // Assign playback info immediately such that all getters return the right values, but keep + // snapshot of previous and new state so that listener invocations are triggered correctly. PlaybackInfo previousPlaybackInfo = this.playbackInfo; + PlaybackInfo newPlaybackInfo = playbackInfo; this.playbackInfo = playbackInfo; Pair mediaItemTransitionInfo = evaluateMediaItemTransitionReason( - playbackInfo, + newPlaybackInfo, previousPlaybackInfo, positionDiscontinuity, positionDiscontinuityReason, - !previousPlaybackInfo.timeline.equals(playbackInfo.timeline)); + !previousPlaybackInfo.timeline.equals(newPlaybackInfo.timeline)); boolean mediaItemTransitioned = mediaItemTransitionInfo.first; int mediaItemTransitionReason = mediaItemTransitionInfo.second; - @Nullable MediaItem newMediaItem = null; - if (mediaItemTransitioned && !playbackInfo.timeline.isEmpty()) { - int windowIndex = - playbackInfo.timeline.getPeriodByUid(playbackInfo.periodId.periodUid, period).windowIndex; - newMediaItem = playbackInfo.timeline.getWindow(windowIndex, window).mediaItem; + if (!previousPlaybackInfo.timeline.equals(newPlaybackInfo.timeline)) { + listeners.queueEvent( + listener -> listener.onTimelineChanged(newPlaybackInfo.timeline, timelineChangeReason)); } - notifyListeners( - new PlaybackInfoUpdate( - playbackInfo, - previousPlaybackInfo, - listeners, - trackSelector, - positionDiscontinuity, - positionDiscontinuityReason, - timelineChangeReason, - mediaItemTransitioned, - mediaItemTransitionReason, - newMediaItem, - playWhenReadyChangeReason, - seekProcessed)); + if (positionDiscontinuity) { + listeners.queueEvent( + listener -> listener.onPositionDiscontinuity(positionDiscontinuityReason)); + } + if (mediaItemTransitioned) { + @Nullable final MediaItem mediaItem; + if (!newPlaybackInfo.timeline.isEmpty()) { + int windowIndex = + newPlaybackInfo.timeline.getPeriodByUid(newPlaybackInfo.periodId.periodUid, period) + .windowIndex; + mediaItem = newPlaybackInfo.timeline.getWindow(windowIndex, window).mediaItem; + } else { + mediaItem = null; + } + listeners.queueEvent( + listener -> listener.onMediaItemTransition(mediaItem, mediaItemTransitionReason)); + } + if (previousPlaybackInfo.playbackError != newPlaybackInfo.playbackError + && newPlaybackInfo.playbackError != null) { + listeners.queueEvent(listener -> listener.onPlayerError(newPlaybackInfo.playbackError)); + } + if (previousPlaybackInfo.trackSelectorResult != newPlaybackInfo.trackSelectorResult) { + trackSelector.onSelectionActivated(newPlaybackInfo.trackSelectorResult.info); + listeners.queueEvent( + listener -> + listener.onTracksChanged( + newPlaybackInfo.trackGroups, newPlaybackInfo.trackSelectorResult.selections)); + } + if (!previousPlaybackInfo.staticMetadata.equals(newPlaybackInfo.staticMetadata)) { + listeners.queueEvent( + listener -> listener.onStaticMetadataChanged(newPlaybackInfo.staticMetadata)); + } + if (previousPlaybackInfo.isLoading != newPlaybackInfo.isLoading) { + listeners.queueEvent(listener -> listener.onIsLoadingChanged(newPlaybackInfo.isLoading)); + } + if (previousPlaybackInfo.playbackState != newPlaybackInfo.playbackState + || previousPlaybackInfo.playWhenReady != newPlaybackInfo.playWhenReady) { + listeners.queueEvent( + listener -> + listener.onPlayerStateChanged( + newPlaybackInfo.playWhenReady, newPlaybackInfo.playbackState)); + } + if (previousPlaybackInfo.playbackState != newPlaybackInfo.playbackState) { + listeners.queueEvent( + listener -> listener.onPlaybackStateChanged(newPlaybackInfo.playbackState)); + } + if (previousPlaybackInfo.playWhenReady != newPlaybackInfo.playWhenReady) { + listeners.queueEvent( + listener -> + listener.onPlayWhenReadyChanged( + newPlaybackInfo.playWhenReady, playWhenReadyChangeReason)); + } + if (previousPlaybackInfo.playbackSuppressionReason + != newPlaybackInfo.playbackSuppressionReason) { + listeners.queueEvent( + listener -> + listener.onPlaybackSuppressionReasonChanged( + newPlaybackInfo.playbackSuppressionReason)); + } + if (isPlaying(previousPlaybackInfo) != isPlaying(newPlaybackInfo)) { + listeners.queueEvent(listener -> listener.onIsPlayingChanged(isPlaying(newPlaybackInfo))); + } + if (!previousPlaybackInfo.playbackParameters.equals(newPlaybackInfo.playbackParameters)) { + listeners.queueEvent( + listener -> listener.onPlaybackParametersChanged(newPlaybackInfo.playbackParameters)); + } + if (seekProcessed) { + listeners.queueEvent(EventListener::onSeekProcessed); + } + if (previousPlaybackInfo.offloadSchedulingEnabled != newPlaybackInfo.offloadSchedulingEnabled) { + listeners.queueEvent( + listener -> + listener.onExperimentalOffloadSchedulingEnabledChanged( + newPlaybackInfo.offloadSchedulingEnabled)); + } + if (previousPlaybackInfo.sleepingForOffload != newPlaybackInfo.sleepingForOffload) { + listeners.queueEvent( + listener -> + listener.onExperimentalSleepingForOffloadChanged(newPlaybackInfo.sleepingForOffload)); + } + listeners.flushEvents(); } private Pair evaluateMediaItemTransitionReason( @@ -1332,23 +1392,6 @@ import java.util.concurrent.TimeoutException; return timeline.getPeriodPosition(window, period, windowIndex, C.msToUs(windowPositionMs)); } - private void notifyListeners(ListenerInvocation listenerInvocation) { - CopyOnWriteArrayList 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); @@ -1356,183 +1399,10 @@ import java.util.concurrent.TimeoutException; return positionMs; } - private static final class PlaybackInfoUpdate implements Runnable { - - private final PlaybackInfo playbackInfo; - private final CopyOnWriteArrayList listenerSnapshot; - private final TrackSelector trackSelector; - private final boolean positionDiscontinuity; - @DiscontinuityReason private final int positionDiscontinuityReason; - @TimelineChangeReason private final int timelineChangeReason; - private final boolean mediaItemTransitioned; - private final int mediaItemTransitionReason; - @Nullable private final MediaItem mediaItem; - @PlayWhenReadyChangeReason private final int playWhenReadyChangeReason; - private final boolean seekProcessed; - private final boolean playbackStateChanged; - private final boolean playbackErrorChanged; - private final boolean isLoadingChanged; - private final boolean timelineChanged; - private final boolean trackSelectorResultChanged; - private final boolean staticMetadataChanged; - private final boolean playWhenReadyChanged; - private final boolean playbackSuppressionReasonChanged; - private final boolean isPlayingChanged; - private final boolean playbackParametersChanged; - private final boolean offloadSchedulingEnabledChanged; - private final boolean sleepingForOffloadChanged; - - public PlaybackInfoUpdate( - PlaybackInfo playbackInfo, - PlaybackInfo previousPlaybackInfo, - CopyOnWriteArrayList listeners, - TrackSelector trackSelector, - boolean positionDiscontinuity, - @DiscontinuityReason int positionDiscontinuityReason, - @TimelineChangeReason int timelineChangeReason, - boolean mediaItemTransitioned, - @MediaItemTransitionReason int mediaItemTransitionReason, - @Nullable MediaItem mediaItem, - @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.mediaItemTransitioned = mediaItemTransitioned; - this.mediaItemTransitionReason = mediaItemTransitionReason; - this.mediaItem = mediaItem; - 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; - staticMetadataChanged = - !previousPlaybackInfo.staticMetadata.equals(playbackInfo.staticMetadata); - playWhenReadyChanged = previousPlaybackInfo.playWhenReady != playbackInfo.playWhenReady; - playbackSuppressionReasonChanged = - previousPlaybackInfo.playbackSuppressionReason != playbackInfo.playbackSuppressionReason; - isPlayingChanged = isPlaying(previousPlaybackInfo) != isPlaying(playbackInfo); - playbackParametersChanged = - !previousPlaybackInfo.playbackParameters.equals(playbackInfo.playbackParameters); - offloadSchedulingEnabledChanged = - previousPlaybackInfo.offloadSchedulingEnabled != playbackInfo.offloadSchedulingEnabled; - sleepingForOffloadChanged = - previousPlaybackInfo.sleepingForOffload != playbackInfo.sleepingForOffload; - } - - @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 (mediaItemTransitioned) { - invokeAll( - listenerSnapshot, - listener -> listener.onMediaItemTransition(mediaItem, mediaItemTransitionReason)); - } - 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 (staticMetadataChanged) { - invokeAll( - listenerSnapshot, - listener -> listener.onStaticMetadataChanged(playbackInfo.staticMetadata)); - } - 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 (playbackParametersChanged) { - invokeAll( - listenerSnapshot, - listener -> { - listener.onPlaybackParametersChanged(playbackInfo.playbackParameters); - }); - } - if (seekProcessed) { - invokeAll(listenerSnapshot, EventListener::onSeekProcessed); - } - if (offloadSchedulingEnabledChanged) { - invokeAll( - listenerSnapshot, - listener -> - listener.onExperimentalOffloadSchedulingEnabledChanged( - playbackInfo.offloadSchedulingEnabled)); - } - if (sleepingForOffloadChanged) { - invokeAll( - listenerSnapshot, - listener -> - listener.onExperimentalSleepingForOffloadChanged(playbackInfo.sleepingForOffload)); - } - } - - 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 listeners, ListenerInvocation listenerInvocation) { - for (ListenerHolder listenerHolder : listeners) { - listenerHolder.invoke(listenerInvocation); - } + private static boolean isPlaying(PlaybackInfo playbackInfo) { + return playbackInfo.playbackState == Player.STATE_READY + && playbackInfo.playWhenReady + && playbackInfo.playbackSuppressionReason == PLAYBACK_SUPPRESSION_REASON_NONE; } private static final class MediaSourceHolderSnapshot implements MediaSourceInfoHolder { diff --git a/library/core/src/main/java/com/google/android/exoplayer2/util/ListenerSet.java b/library/core/src/main/java/com/google/android/exoplayer2/util/ListenerSet.java new file mode 100644 index 0000000000..12b73ec94f --- /dev/null +++ b/library/core/src/main/java/com/google/android/exoplayer2/util/ListenerSet.java @@ -0,0 +1,163 @@ +/* + * Copyright (C) 2020 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.util; + +import androidx.annotation.Nullable; +import java.util.ArrayDeque; +import java.util.concurrent.CopyOnWriteArraySet; +import javax.annotation.Nonnull; + +/** + * A set of listeners. + * + *

Events are guaranteed to arrive in the order in which they happened even if a new event is + * triggered recursively from another listener. + * + *

Events are also guaranteed to be only sent to the listeners registered at the time the event + * was enqueued and haven't been removed since. + * + * @param The listener type. + */ +public final class ListenerSet { + + /** + * An event sent to a listener. + * + * @param The listener type. + */ + public interface Event { + + /** Invokes the event notification on the given listener. */ + void invoke(T listener); + } + + private final CopyOnWriteArraySet> listeners; + private final ArrayDeque flushingEvents; + private final ArrayDeque queuedEvents; + + /** Creates the listener set. */ + public ListenerSet() { + listeners = new CopyOnWriteArraySet<>(); + flushingEvents = new ArrayDeque<>(); + queuedEvents = new ArrayDeque<>(); + } + + /** + * Adds a listener to the set. + * + *

If a listener is already present, it will not be added again. + * + * @param listener The listener to be added. + */ + public void add(T listener) { + Assertions.checkNotNull(listener); + listeners.add(new ListenerHolder(listener)); + } + + /** + * Removes a listener from the set. + * + *

If the listener is not present, nothing happens. + * + * @param listener The listener to be removed. + */ + public void remove(T listener) { + for (ListenerHolder listenerHolder : listeners) { + if (listenerHolder.listener.equals(listener)) { + listenerHolder.release(); + listeners.remove(listenerHolder); + } + } + } + + /** + * Adds an event that is sent to the listeners when {@link #flushEvents} is called. + * + * @param event The event. + */ + public void queueEvent(Event event) { + CopyOnWriteArraySet> listenerSnapshot = new CopyOnWriteArraySet<>(listeners); + queuedEvents.add( + () -> { + for (ListenerHolder holder : listenerSnapshot) { + holder.invoke(event); + } + }); + } + + /** Notifies listeners of events previously enqueued with {@link #queueEvent(Event)}. */ + public void flushEvents() { + boolean recursiveFlushInProgress = !flushingEvents.isEmpty(); + flushingEvents.addAll(queuedEvents); + queuedEvents.clear(); + if (recursiveFlushInProgress) { + // Recursive call to flush. Let the outer call handle the flush queue. + return; + } + while (!flushingEvents.isEmpty()) { + flushingEvents.peekFirst().run(); + flushingEvents.removeFirst(); + } + } + + /** + * {@link #queueEvent(Event) Queues} a single event and immediately {@link #flushEvents() flushes} + * the event queue to notify all listeners. + * + * @param event The event. + */ + public void sendEvent(Event event) { + queueEvent(event); + flushEvents(); + } + + private static final class ListenerHolder { + + @Nonnull public final T listener; + + private boolean released; + + public ListenerHolder(@Nonnull T listener) { + this.listener = listener; + } + + public void release() { + released = true; + } + + public void invoke(Event event) { + if (!released) { + event.invoke(listener); + } + } + + @Override + public boolean equals(@Nullable Object other) { + if (this == other) { + return true; + } + if (other == null || getClass() != other.getClass()) { + return false; + } + return listener.equals(((ListenerHolder) other).listener); + } + + @Override + public int hashCode() { + return listener.hashCode(); + } + } +} diff --git a/library/core/src/test/java/com/google/android/exoplayer2/util/ListenerSetTest.java b/library/core/src/test/java/com/google/android/exoplayer2/util/ListenerSetTest.java new file mode 100644 index 0000000000..36f20c065e --- /dev/null +++ b/library/core/src/test/java/com/google/android/exoplayer2/util/ListenerSetTest.java @@ -0,0 +1,194 @@ +/* + * Copyright (C) 2020 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.util; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InOrder; +import org.mockito.Mockito; + +/** Unit test for {@link ListenerSet}. */ +@RunWith(AndroidJUnit4.class) +public class ListenerSetTest { + + @Test + public void queueEvent_isNotSentWithoutFlush() { + ListenerSet listenerSet = new ListenerSet<>(); + TestListener listener = mock(TestListener.class); + listenerSet.add(listener); + + listenerSet.queueEvent(TestListener::callback1); + listenerSet.queueEvent(TestListener::callback2); + + verifyNoMoreInteractions(listener); + } + + @Test + public void flushEvents_sendsPreviouslyQueuedEventsToAllListeners() { + ListenerSet listenerSet = new ListenerSet<>(); + TestListener listener1 = mock(TestListener.class); + TestListener listener2 = mock(TestListener.class); + listenerSet.add(listener1); + listenerSet.add(listener2); + + listenerSet.queueEvent(TestListener::callback1); + listenerSet.queueEvent(TestListener::callback2); + listenerSet.queueEvent(TestListener::callback1); + listenerSet.flushEvents(); + + InOrder inOrder = Mockito.inOrder(listener1, listener2); + inOrder.verify(listener1).callback1(); + inOrder.verify(listener2).callback1(); + inOrder.verify(listener1).callback2(); + inOrder.verify(listener2).callback2(); + inOrder.verify(listener1).callback1(); + inOrder.verify(listener2).callback1(); + inOrder.verifyNoMoreInteractions(); + } + + @Test + public void flushEvents_recursive_sendsEventsInCorrectOrder() { + ListenerSet listenerSet = new ListenerSet<>(); + // Listener1 sends callback3 recursively when receiving callback1. + TestListener listener1 = + spy( + new TestListener() { + @Override + public void callback1() { + listenerSet.queueEvent(TestListener::callback3); + listenerSet.flushEvents(); + } + }); + TestListener listener2 = mock(TestListener.class); + listenerSet.add(listener1); + listenerSet.add(listener2); + + listenerSet.queueEvent(TestListener::callback1); + listenerSet.queueEvent(TestListener::callback2); + listenerSet.flushEvents(); + + InOrder inOrder = Mockito.inOrder(listener1, listener2); + inOrder.verify(listener1).callback1(); + inOrder.verify(listener2).callback1(); + inOrder.verify(listener1).callback2(); + inOrder.verify(listener2).callback2(); + inOrder.verify(listener1).callback3(); + inOrder.verify(listener2).callback3(); + inOrder.verifyNoMoreInteractions(); + } + + @Test + public void add_withRecursion_onlyReceivesUpdatesForFutureEvents() { + ListenerSet listenerSet = new ListenerSet<>(); + TestListener listener2 = mock(TestListener.class); + // Listener1 adds listener2 recursively. + TestListener listener1 = + spy( + new TestListener() { + @Override + public void callback1() { + listenerSet.add(listener2); + } + }); + + listenerSet.sendEvent(TestListener::callback1); + listenerSet.add(listener1); + // This should add listener2, but the event should not be received yet as it happened before + // listener2 was added. + listenerSet.sendEvent(TestListener::callback1); + listenerSet.sendEvent(TestListener::callback1); + + verify(listener1, times(2)).callback1(); + verify(listener2).callback1(); + } + + @Test + public void add_withQueueing_onlyReceivesUpdatesForFutureEvents() { + ListenerSet listenerSet = new ListenerSet<>(); + TestListener listener1 = mock(TestListener.class); + TestListener listener2 = mock(TestListener.class); + + // This event is flushed after listener2 was added, but shouldn't be sent to listener2 because + // the event itself occurred before the listener was added. + listenerSet.add(listener1); + listenerSet.queueEvent(TestListener::callback2); + listenerSet.add(listener2); + listenerSet.queueEvent(TestListener::callback2); + listenerSet.flushEvents(); + + verify(listener1, times(2)).callback2(); + verify(listener2).callback2(); + } + + @Test + public void remove_withRecursion_stopsReceivingEventsImmediately() { + ListenerSet listenerSet = new ListenerSet<>(); + TestListener listener2 = mock(TestListener.class); + // Listener1 removes listener2 recursively. + TestListener listener1 = + spy( + new TestListener() { + @Override + public void callback1() { + listenerSet.remove(listener2); + } + }); + listenerSet.add(listener1); + listenerSet.add(listener2); + + // Listener2 shouldn't even get this event as it's removed before the event can be invoked. + listenerSet.sendEvent(TestListener::callback1); + listenerSet.remove(listener1); + listenerSet.sendEvent(TestListener::callback1); + + verify(listener1).callback1(); + verify(listener2, never()).callback1(); + } + + @Test + public void remove_withQueueing_stopsReceivingEventsImmediately() { + ListenerSet listenerSet = new ListenerSet<>(); + TestListener listener1 = mock(TestListener.class); + TestListener listener2 = mock(TestListener.class); + listenerSet.add(listener1); + listenerSet.add(listener2); + + // Listener1 shouldn't even get this event as it's removed before the event can be invoked. + listenerSet.queueEvent(TestListener::callback1); + listenerSet.remove(listener1); + listenerSet.queueEvent(TestListener::callback1); + listenerSet.flushEvents(); + + verify(listener1, never()).callback1(); + verify(listener2, times(2)).callback1(); + } + + private interface TestListener { + default void callback1() {} + + default void callback2() {} + + default void callback3() {} + } +}