Improve PlaybackStatsListener by using onEvents callback

Previously the PlaybackStatsListener needed to handle all events
individually, which required to keep some state of the player and
to resolve potentially transient state changes.

Using onEvents allows to channel all simultanous updates through
one method so that no transient player state and other
inconsistencies need to be handled. This makes the logic easier
to read.

In addition it also allows to resolve all simultaneous events to
use one EventTime (with one timestamp).

#exofixit

PiperOrigin-RevId: 344415459
This commit is contained in:
tonihei 2020-11-26 14:30:33 +00:00 committed by Andrew Lewis
parent f1cf3d98d8
commit 3f6ec59868
2 changed files with 384 additions and 572 deletions

View File

@ -15,14 +15,15 @@
*/ */
package com.google.android.exoplayer2.analytics; package com.google.android.exoplayer2.analytics;
import static com.google.android.exoplayer2.util.Assertions.checkNotNull;
import static java.lang.Math.max; import static java.lang.Math.max;
import android.os.SystemClock; import android.os.SystemClock;
import android.util.Pair;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.ExoPlaybackException;
import com.google.android.exoplayer2.Format; import com.google.android.exoplayer2.Format;
import com.google.android.exoplayer2.PlaybackParameters;
import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Player;
import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.Timeline;
import com.google.android.exoplayer2.Timeline.Period; import com.google.android.exoplayer2.Timeline.Period;
@ -33,9 +34,7 @@ import com.google.android.exoplayer2.analytics.PlaybackStats.PlaybackState;
import com.google.android.exoplayer2.source.LoadEventInfo; import com.google.android.exoplayer2.source.LoadEventInfo;
import com.google.android.exoplayer2.source.MediaLoadData; import com.google.android.exoplayer2.source.MediaLoadData;
import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId; import com.google.android.exoplayer2.source.MediaSource.MediaPeriodId;
import com.google.android.exoplayer2.source.TrackGroupArray;
import com.google.android.exoplayer2.trackselection.TrackSelection; import com.google.android.exoplayer2.trackselection.TrackSelection;
import com.google.android.exoplayer2.trackselection.TrackSelectionArray;
import com.google.android.exoplayer2.util.Assertions; import com.google.android.exoplayer2.util.Assertions;
import com.google.android.exoplayer2.util.MimeTypes; import com.google.android.exoplayer2.util.MimeTypes;
import com.google.android.exoplayer2.util.Util; import com.google.android.exoplayer2.util.Util;
@ -82,11 +81,17 @@ public final class PlaybackStatsListener
private PlaybackStats finishedPlaybackStats; private PlaybackStats finishedPlaybackStats;
@Nullable private String activeContentPlayback; @Nullable private String activeContentPlayback;
@Nullable private String activeAdPlayback; @Nullable private String activeAdPlayback;
private boolean playWhenReady;
@Player.State private int playbackState; @Nullable private EventTime onSeekStartedEventTime;
private boolean isSuppressed; @Player.DiscontinuityReason int discontinuityReason;
private float playbackSpeed; int droppedFrames;
private boolean onSeekStartedCalled; @Nullable Exception nonFatalException;
long bandwidthTimeMs;
long bandwidthBytes;
@Nullable Format videoFormat;
@Nullable Format audioFormat;
int videoHeight;
int videoWidth;
/** /**
* Creates listener for playback stats. * Creates listener for playback stats.
@ -102,9 +107,6 @@ public final class PlaybackStatsListener
playbackStatsTrackers = new HashMap<>(); playbackStatsTrackers = new HashMap<>();
sessionStartEventTimes = new HashMap<>(); sessionStartEventTimes = new HashMap<>();
finishedPlaybackStats = PlaybackStats.EMPTY; finishedPlaybackStats = PlaybackStats.EMPTY;
playWhenReady = false;
playbackState = Player.STATE_IDLE;
playbackSpeed = 1f;
period = new Period(); period = new Period();
sessionManager.setListener(this); sessionManager.setListener(this);
} }
@ -172,20 +174,13 @@ public final class PlaybackStatsListener
@Override @Override
public void onSessionCreated(EventTime eventTime, String session) { public void onSessionCreated(EventTime eventTime, String session) {
PlaybackStatsTracker tracker = new PlaybackStatsTracker(keepHistory, eventTime); PlaybackStatsTracker tracker = new PlaybackStatsTracker(keepHistory, eventTime);
if (onSeekStartedCalled) {
tracker.onSeekStarted(eventTime, /* belongsToPlayback= */ true);
}
tracker.onPlaybackStateChanged(eventTime, playbackState, /* belongsToPlayback= */ true);
tracker.onPlayWhenReadyChanged(eventTime, playWhenReady, /* belongsToPlayback= */ true);
tracker.onIsSuppressedChanged(eventTime, isSuppressed, /* belongsToPlayback= */ true);
tracker.onPlaybackSpeedChanged(eventTime, playbackSpeed);
playbackStatsTrackers.put(session, tracker); playbackStatsTrackers.put(session, tracker);
sessionStartEventTimes.put(session, eventTime); sessionStartEventTimes.put(session, eventTime);
} }
@Override @Override
public void onSessionActive(EventTime eventTime, String session) { public void onSessionActive(EventTime eventTime, String session) {
Assertions.checkNotNull(playbackStatsTrackers.get(session)).onForeground(eventTime); checkNotNull(playbackStatsTrackers.get(session)).onForeground();
if (eventTime.mediaPeriodId != null && eventTime.mediaPeriodId.isAd()) { if (eventTime.mediaPeriodId != null && eventTime.mediaPeriodId.isAd()) {
activeAdPlayback = session; activeAdPlayback = session;
} else { } else {
@ -195,33 +190,7 @@ public final class PlaybackStatsListener
@Override @Override
public void onAdPlaybackStarted(EventTime eventTime, String contentSession, String adSession) { public void onAdPlaybackStarted(EventTime eventTime, String contentSession, String adSession) {
Assertions.checkState(Assertions.checkNotNull(eventTime.mediaPeriodId).isAd()); checkNotNull(playbackStatsTrackers.get(contentSession)).onInterruptedByAd();
long contentPeriodPositionUs =
eventTime
.timeline
.getPeriodByUid(eventTime.mediaPeriodId.periodUid, period)
.getAdGroupTimeUs(eventTime.mediaPeriodId.adGroupIndex);
long contentWindowPositionUs =
contentPeriodPositionUs == C.TIME_END_OF_SOURCE
? C.TIME_END_OF_SOURCE
: contentPeriodPositionUs + period.getPositionInWindowUs();
EventTime contentEventTime =
new EventTime(
eventTime.realtimeMs,
eventTime.timeline,
eventTime.windowIndex,
new MediaPeriodId(
eventTime.mediaPeriodId.periodUid,
eventTime.mediaPeriodId.windowSequenceNumber,
eventTime.mediaPeriodId.adGroupIndex),
/* eventPlaybackPositionMs= */ C.usToMs(contentWindowPositionUs),
eventTime.timeline,
eventTime.currentWindowIndex,
eventTime.currentMediaPeriodId,
eventTime.currentPlaybackPositionMs,
eventTime.totalBufferedDurationMs);
Assertions.checkNotNull(playbackStatsTrackers.get(contentSession))
.onInterruptedByAd(contentEventTime);
} }
@Override @Override
@ -231,13 +200,9 @@ public final class PlaybackStatsListener
} else if (session.equals(activeContentPlayback)) { } else if (session.equals(activeContentPlayback)) {
activeContentPlayback = null; activeContentPlayback = null;
} }
PlaybackStatsTracker tracker = Assertions.checkNotNull(playbackStatsTrackers.remove(session)); PlaybackStatsTracker tracker = checkNotNull(playbackStatsTrackers.remove(session));
EventTime startEventTime = Assertions.checkNotNull(sessionStartEventTimes.remove(session)); EventTime startEventTime = checkNotNull(sessionStartEventTimes.remove(session));
if (automaticTransition) { tracker.onFinished(eventTime, automaticTransition);
// Simulate ENDED state to record natural ending of playback.
tracker.onPlaybackStateChanged(eventTime, Player.STATE_ENDED, /* belongsToPlayback= */ false);
}
tracker.onFinished(eventTime);
PlaybackStats playbackStats = tracker.build(/* isFinal= */ true); PlaybackStats playbackStats = tracker.build(/* isFinal= */ true);
finishedPlaybackStats = PlaybackStats.merge(finishedPlaybackStats, playbackStats); finishedPlaybackStats = PlaybackStats.merge(finishedPlaybackStats, playbackStats);
if (callback != null) { if (callback != null) {
@ -247,180 +212,19 @@ public final class PlaybackStatsListener
// AnalyticsListener implementation. // AnalyticsListener implementation.
@Override
public void onPlaybackStateChanged(EventTime eventTime, @Player.State int state) {
playbackState = state;
maybeAddSession(eventTime);
for (String session : playbackStatsTrackers.keySet()) {
boolean belongsToPlayback = sessionManager.belongsToSession(eventTime, session);
playbackStatsTrackers
.get(session)
.onPlaybackStateChanged(eventTime, playbackState, belongsToPlayback);
}
}
@Override
public void onPlayWhenReadyChanged(
EventTime eventTime, boolean playWhenReady, @Player.PlayWhenReadyChangeReason int reason) {
this.playWhenReady = playWhenReady;
maybeAddSession(eventTime);
for (String session : playbackStatsTrackers.keySet()) {
boolean belongsToPlayback = sessionManager.belongsToSession(eventTime, session);
playbackStatsTrackers
.get(session)
.onPlayWhenReadyChanged(eventTime, playWhenReady, belongsToPlayback);
}
}
@Override
public void onPlaybackSuppressionReasonChanged(
EventTime eventTime, @Player.PlaybackSuppressionReason int playbackSuppressionReason) {
isSuppressed = playbackSuppressionReason != Player.PLAYBACK_SUPPRESSION_REASON_NONE;
maybeAddSession(eventTime);
for (String session : playbackStatsTrackers.keySet()) {
boolean belongsToPlayback = sessionManager.belongsToSession(eventTime, session);
playbackStatsTrackers
.get(session)
.onIsSuppressedChanged(eventTime, isSuppressed, belongsToPlayback);
}
}
@Override
public void onTimelineChanged(EventTime eventTime, @Player.TimelineChangeReason int reason) {
sessionManager.updateSessionsWithTimelineChange(eventTime);
for (String session : playbackStatsTrackers.keySet()) {
if (sessionManager.belongsToSession(eventTime, session)) {
playbackStatsTrackers.get(session).onPositionDiscontinuity(eventTime, /* isSeek= */ false);
}
}
}
@Override @Override
public void onPositionDiscontinuity(EventTime eventTime, @Player.DiscontinuityReason int reason) { public void onPositionDiscontinuity(EventTime eventTime, @Player.DiscontinuityReason int reason) {
boolean isCompletelyIdle = eventTime.timeline.isEmpty() && playbackState == Player.STATE_IDLE; discontinuityReason = reason;
if (!isCompletelyIdle) {
sessionManager.updateSessionsWithDiscontinuity(eventTime, reason);
}
if (reason == Player.DISCONTINUITY_REASON_SEEK) {
onSeekStartedCalled = false;
}
for (String session : playbackStatsTrackers.keySet()) {
if (sessionManager.belongsToSession(eventTime, session)) {
playbackStatsTrackers
.get(session)
.onPositionDiscontinuity(
eventTime, /* isSeek= */ reason == Player.DISCONTINUITY_REASON_SEEK);
}
}
} }
@Override @Override
public void onSeekStarted(EventTime eventTime) { public void onSeekStarted(EventTime eventTime) {
maybeAddSession(eventTime); onSeekStartedEventTime = eventTime;
for (String session : playbackStatsTrackers.keySet()) {
boolean belongsToPlayback = sessionManager.belongsToSession(eventTime, session);
playbackStatsTrackers.get(session).onSeekStarted(eventTime, belongsToPlayback);
}
onSeekStartedCalled = true;
}
@Override
public void onPlayerError(EventTime eventTime, ExoPlaybackException error) {
maybeAddSession(eventTime);
for (String session : playbackStatsTrackers.keySet()) {
if (sessionManager.belongsToSession(eventTime, session)) {
playbackStatsTrackers.get(session).onFatalError(eventTime, error);
}
}
}
@Override
public void onPlaybackParametersChanged(
EventTime eventTime, PlaybackParameters playbackParameters) {
playbackSpeed = playbackParameters.speed;
maybeAddSession(eventTime);
for (PlaybackStatsTracker tracker : playbackStatsTrackers.values()) {
tracker.onPlaybackSpeedChanged(eventTime, playbackSpeed);
}
}
@Override
public void onTracksChanged(
EventTime eventTime, TrackGroupArray trackGroups, TrackSelectionArray trackSelections) {
maybeAddSession(eventTime);
for (String session : playbackStatsTrackers.keySet()) {
if (sessionManager.belongsToSession(eventTime, session)) {
playbackStatsTrackers.get(session).onTracksChanged(eventTime, trackSelections);
}
}
}
@Override
public void onLoadStarted(
EventTime eventTime, LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData) {
maybeAddSession(eventTime);
for (String session : playbackStatsTrackers.keySet()) {
if (sessionManager.belongsToSession(eventTime, session)) {
playbackStatsTrackers.get(session).onLoadStarted(eventTime);
}
}
}
@Override
public void onDownstreamFormatChanged(EventTime eventTime, MediaLoadData mediaLoadData) {
maybeAddSession(eventTime);
for (String session : playbackStatsTrackers.keySet()) {
if (sessionManager.belongsToSession(eventTime, session)) {
playbackStatsTrackers.get(session).onDownstreamFormatChanged(eventTime, mediaLoadData);
}
}
}
@Override
public void onVideoSizeChanged(
EventTime eventTime,
int width,
int height,
int unappliedRotationDegrees,
float pixelWidthHeightRatio) {
maybeAddSession(eventTime);
for (String session : playbackStatsTrackers.keySet()) {
if (sessionManager.belongsToSession(eventTime, session)) {
playbackStatsTrackers.get(session).onVideoSizeChanged(eventTime, width, height);
}
}
}
@Override
public void onBandwidthEstimate(
EventTime eventTime, int totalLoadTimeMs, long totalBytesLoaded, long bitrateEstimate) {
maybeAddSession(eventTime);
for (String session : playbackStatsTrackers.keySet()) {
if (sessionManager.belongsToSession(eventTime, session)) {
playbackStatsTrackers.get(session).onBandwidthData(totalLoadTimeMs, totalBytesLoaded);
}
}
}
@Override
public void onAudioUnderrun(
EventTime eventTime, int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) {
maybeAddSession(eventTime);
for (String session : playbackStatsTrackers.keySet()) {
if (sessionManager.belongsToSession(eventTime, session)) {
playbackStatsTrackers.get(session).onAudioUnderrun();
}
}
} }
@Override @Override
public void onDroppedVideoFrames(EventTime eventTime, int droppedFrames, long elapsedMs) { public void onDroppedVideoFrames(EventTime eventTime, int droppedFrames, long elapsedMs) {
maybeAddSession(eventTime); this.droppedFrames = droppedFrames;
for (String session : playbackStatsTrackers.keySet()) {
if (sessionManager.belongsToSession(eventTime, session)) {
playbackStatsTrackers.get(session).onDroppedVideoFrames(droppedFrames);
}
}
} }
@Override @Override
@ -430,29 +234,155 @@ public final class PlaybackStatsListener
MediaLoadData mediaLoadData, MediaLoadData mediaLoadData,
IOException error, IOException error,
boolean wasCanceled) { boolean wasCanceled) {
maybeAddSession(eventTime); nonFatalException = error;
for (String session : playbackStatsTrackers.keySet()) {
if (sessionManager.belongsToSession(eventTime, session)) {
playbackStatsTrackers.get(session).onNonFatalError(eventTime, error);
}
}
} }
@Override @Override
public void onDrmSessionManagerError(EventTime eventTime, Exception error) { public void onDrmSessionManagerError(EventTime eventTime, Exception error) {
maybeAddSession(eventTime); nonFatalException = error;
}
@Override
public void onBandwidthEstimate(
EventTime eventTime, int totalLoadTimeMs, long totalBytesLoaded, long bitrateEstimate) {
bandwidthTimeMs = totalLoadTimeMs;
bandwidthBytes = totalBytesLoaded;
}
@Override
public void onDownstreamFormatChanged(EventTime eventTime, MediaLoadData mediaLoadData) {
if (mediaLoadData.trackType == C.TRACK_TYPE_VIDEO
|| mediaLoadData.trackType == C.TRACK_TYPE_DEFAULT) {
videoFormat = mediaLoadData.trackFormat;
} else if (mediaLoadData.trackType == C.TRACK_TYPE_AUDIO) {
audioFormat = mediaLoadData.trackFormat;
}
}
@Override
public void onVideoSizeChanged(
EventTime eventTime, int width, int height, int rotationDegrees, float pixelRatio) {
videoWidth = width;
videoHeight = height;
}
@Override
public void onEvents(Player player, Events events) {
if (events.size() == 0) {
return;
}
maybeAddSessions(player, events);
for (String session : playbackStatsTrackers.keySet()) { for (String session : playbackStatsTrackers.keySet()) {
if (sessionManager.belongsToSession(eventTime, session)) { Pair<EventTime, Boolean> eventTimeAndBelongsToPlayback = findBestEventTime(events, session);
playbackStatsTrackers.get(session).onNonFatalError(eventTime, error); PlaybackStatsTracker tracker = playbackStatsTrackers.get(session);
boolean hasPositionDiscontinuity =
hasEvent(events, session, EVENT_POSITION_DISCONTINUITY)
|| hasEvent(events, session, EVENT_TIMELINE_CHANGED);
boolean hasDroppedFrames = hasEvent(events, session, EVENT_DROPPED_VIDEO_FRAMES);
boolean hasAudioUnderrun = hasEvent(events, session, EVENT_AUDIO_UNDERRUN);
boolean startedLoading = hasEvent(events, session, EVENT_LOAD_STARTED);
boolean hasFatalError = hasEvent(events, session, EVENT_PLAYER_ERROR);
boolean hasNonFatalException =
hasEvent(events, session, EVENT_LOAD_ERROR)
|| hasEvent(events, session, EVENT_DRM_SESSION_MANAGER_ERROR);
boolean hasBandwidthData = hasEvent(events, session, EVENT_BANDWIDTH_ESTIMATE);
boolean hasFormatData = hasEvent(events, session, EVENT_DOWNSTREAM_FORMAT_CHANGED);
boolean hasVideoSize = hasEvent(events, session, EVENT_VIDEO_SIZE_CHANGED);
tracker.onEvents(
player,
/* eventTime= */ eventTimeAndBelongsToPlayback.first,
/* belongsToPlayback= */ eventTimeAndBelongsToPlayback.second,
/* seeked= */ onSeekStartedEventTime != null,
hasPositionDiscontinuity,
hasDroppedFrames ? droppedFrames : 0,
hasAudioUnderrun,
startedLoading,
hasFatalError ? player.getPlayerError() : null,
hasNonFatalException ? nonFatalException : null,
hasBandwidthData ? bandwidthTimeMs : 0,
hasBandwidthData ? bandwidthBytes : 0,
hasFormatData ? videoFormat : null,
hasFormatData ? audioFormat : null,
hasVideoSize ? videoHeight : Format.NO_VALUE,
hasVideoSize ? videoWidth : Format.NO_VALUE);
}
onSeekStartedEventTime = null;
videoFormat = null;
audioFormat = null;
}
private void maybeAddSessions(Player player, Events events) {
if (player.getCurrentTimeline().isEmpty() && player.getPlaybackState() == Player.STATE_IDLE) {
// Player is completely idle. Don't add new sessions.
return;
}
for (int i = 0; i < events.size(); i++) {
@EventFlags int event = events.get(i);
EventTime eventTime = events.getEventTime(event);
if (event == EVENT_TIMELINE_CHANGED) {
sessionManager.updateSessionsWithTimelineChange(eventTime);
} else if (event == EVENT_POSITION_DISCONTINUITY) {
sessionManager.updateSessionsWithDiscontinuity(eventTime, discontinuityReason);
} else {
sessionManager.updateSessions(eventTime);
} }
} }
} }
private void maybeAddSession(EventTime eventTime) { private Pair<EventTime, Boolean> findBestEventTime(Events events, String session) {
boolean isCompletelyIdle = eventTime.timeline.isEmpty() && playbackState == Player.STATE_IDLE; // Check all event times of the events as well as the event time when a seek started.
if (!isCompletelyIdle) { @Nullable EventTime eventTime = onSeekStartedEventTime;
sessionManager.updateSessions(eventTime); boolean belongsToPlayback =
onSeekStartedEventTime != null
&& sessionManager.belongsToSession(onSeekStartedEventTime, session);
for (int i = 0; i < events.size(); i++) {
@EventFlags int event = events.get(i);
EventTime newEventTime = events.getEventTime(event);
boolean newBelongsToPlayback = sessionManager.belongsToSession(newEventTime, session);
if (eventTime == null
|| (newBelongsToPlayback && !belongsToPlayback)
|| (newBelongsToPlayback == belongsToPlayback
&& newEventTime.realtimeMs > eventTime.realtimeMs)) {
// Prefer event times for the current playback and prefer later timestamps.
eventTime = newEventTime;
belongsToPlayback = newBelongsToPlayback;
}
} }
checkNotNull(eventTime);
if (!belongsToPlayback && eventTime.mediaPeriodId != null && eventTime.mediaPeriodId.isAd()) {
// Replace ad event time with content event time unless it's for the ad playback itself.
long contentPeriodPositionUs =
eventTime
.timeline
.getPeriodByUid(eventTime.mediaPeriodId.periodUid, period)
.getAdGroupTimeUs(eventTime.mediaPeriodId.adGroupIndex);
if (contentPeriodPositionUs == C.TIME_END_OF_SOURCE) {
contentPeriodPositionUs = period.durationUs;
}
long contentWindowPositionUs = contentPeriodPositionUs + period.getPositionInWindowUs();
eventTime =
new EventTime(
eventTime.realtimeMs,
eventTime.timeline,
eventTime.windowIndex,
new MediaPeriodId(
eventTime.mediaPeriodId.periodUid,
eventTime.mediaPeriodId.windowSequenceNumber,
eventTime.mediaPeriodId.adGroupIndex),
/* eventPlaybackPositionMs= */ C.usToMs(contentWindowPositionUs),
eventTime.timeline,
eventTime.currentWindowIndex,
eventTime.currentMediaPeriodId,
eventTime.currentPlaybackPositionMs,
eventTime.totalBufferedDurationMs);
belongsToPlayback = sessionManager.belongsToSession(eventTime, session);
}
return Pair.create(eventTime, belongsToPlayback);
}
private boolean hasEvent(Events events, String session, @EventFlags int event) {
return events.contains(event)
&& sessionManager.belongsToSession(events.getEventTime(event), session);
} }
/** Tracker for playback stats of a single playback. */ /** Tracker for playback stats of a single playback. */
@ -500,10 +430,6 @@ public final class PlaybackStatsListener
private boolean isSeeking; private boolean isSeeking;
private boolean isForeground; private boolean isForeground;
private boolean isInterruptedByAd; private boolean isInterruptedByAd;
private boolean isFinished;
private boolean playWhenReady;
@Player.State private int playerPlaybackState;
private boolean isSuppressed;
private boolean hasFatalError; private boolean hasFatalError;
private boolean startedLoading; private boolean startedLoading;
private long lastRebufferStartTimeMs; private long lastRebufferStartTimeMs;
@ -530,7 +456,6 @@ public final class PlaybackStatsListener
nonFatalErrorHistory = keepHistory ? new ArrayList<>() : Collections.emptyList(); nonFatalErrorHistory = keepHistory ? new ArrayList<>() : Collections.emptyList();
currentPlaybackState = PlaybackStats.PLAYBACK_STATE_NOT_STARTED; currentPlaybackState = PlaybackStats.PLAYBACK_STATE_NOT_STARTED;
currentPlaybackStateStartTimeMs = startTime.realtimeMs; currentPlaybackStateStartTimeMs = startTime.realtimeMs;
playerPlaybackState = Player.STATE_IDLE;
firstReportedTimeMs = C.TIME_UNSET; firstReportedTimeMs = C.TIME_UNSET;
maxRebufferTimeMs = C.TIME_UNSET; maxRebufferTimeMs = C.TIME_UNSET;
isAd = startTime.mediaPeriodId != null && startTime.mediaPeriodId.isAd(); isAd = startTime.mediaPeriodId != null && startTime.mediaPeriodId.isAd();
@ -540,247 +465,156 @@ public final class PlaybackStatsListener
currentPlaybackSpeed = 1f; currentPlaybackSpeed = 1f;
} }
/** /** Notifies the tracker that the current playback became the active foreground playback. */
* Notifies the tracker of a playback state change event, including all playback state changes public void onForeground() {
* while the playback is not in the foreground.
*
* @param eventTime The {@link EventTime}.
* @param state The current {@link Player.State}.
* @param belongsToPlayback Whether the {@code eventTime} belongs to the current playback.
*/
public void onPlaybackStateChanged(
EventTime eventTime, @Player.State int state, boolean belongsToPlayback) {
playerPlaybackState = state;
if (state != Player.STATE_IDLE) {
hasFatalError = false;
}
if (state != Player.STATE_BUFFERING) {
isSeeking = false;
}
if (state == Player.STATE_IDLE || state == Player.STATE_ENDED) {
isInterruptedByAd = false;
}
maybeUpdatePlaybackState(eventTime, belongsToPlayback);
}
/**
* Notifies the tracker of a play when ready change event, including all play when ready changes
* while the playback is not in the foreground.
*
* @param eventTime The {@link EventTime}.
* @param playWhenReady Whether the playback will proceed when ready.
* @param belongsToPlayback Whether the {@code eventTime} belongs to the current playback.
*/
public void onPlayWhenReadyChanged(
EventTime eventTime, boolean playWhenReady, boolean belongsToPlayback) {
this.playWhenReady = playWhenReady;
maybeUpdatePlaybackState(eventTime, belongsToPlayback);
}
/**
* Notifies the tracker of a change to the playback suppression (e.g. due to audio focus loss),
* including all updates while the playback is not in the foreground.
*
* @param eventTime The {@link EventTime}.
* @param isSuppressed Whether playback is suppressed.
* @param belongsToPlayback Whether the {@code eventTime} belongs to the current playback.
*/
public void onIsSuppressedChanged(
EventTime eventTime, boolean isSuppressed, boolean belongsToPlayback) {
this.isSuppressed = isSuppressed;
maybeUpdatePlaybackState(eventTime, belongsToPlayback);
}
/**
* Notifies the tracker of a position discontinuity or timeline update for the current playback.
*
* @param eventTime The {@link EventTime}.
* @param isSeek Whether the position discontinuity is for a seek.
*/
public void onPositionDiscontinuity(EventTime eventTime, boolean isSeek) {
if (isSeek && playerPlaybackState == Player.STATE_IDLE) {
isSeeking = false;
}
isInterruptedByAd = false;
maybeUpdatePlaybackState(eventTime, /* belongsToPlayback= */ true);
}
/**
* Notifies the tracker of the start of a seek, including all seeks while the playback is not in
* the foreground.
*
* @param eventTime The {@link EventTime}.
* @param belongsToPlayback Whether the {@code eventTime} belongs to the current playback.
*/
public void onSeekStarted(EventTime eventTime, boolean belongsToPlayback) {
isSeeking = true;
maybeUpdatePlaybackState(eventTime, belongsToPlayback);
}
/**
* Notifies the tracker of fatal player error in the current playback.
*
* @param eventTime The {@link EventTime}.
*/
public void onFatalError(EventTime eventTime, Exception error) {
fatalErrorCount++;
if (keepHistory) {
fatalErrorHistory.add(new EventTimeAndException(eventTime, error));
}
hasFatalError = true;
isInterruptedByAd = false;
isSeeking = false;
maybeUpdatePlaybackState(eventTime, /* belongsToPlayback= */ true);
}
/**
* Notifies the tracker that a load for the current playback has started.
*
* @param eventTime The {@link EventTime}.
*/
public void onLoadStarted(EventTime eventTime) {
startedLoading = true;
maybeUpdatePlaybackState(eventTime, /* belongsToPlayback= */ true);
}
/**
* Notifies the tracker that the current playback became the active foreground playback.
*
* @param eventTime The {@link EventTime}.
*/
public void onForeground(EventTime eventTime) {
isForeground = true; isForeground = true;
maybeUpdatePlaybackState(eventTime, /* belongsToPlayback= */ true);
} }
/** /** Notifies the tracker that the current playback is interrupted by an ad. */
* Notifies the tracker that the current playback has been interrupted for ad playback. public void onInterruptedByAd() {
*
* @param eventTime The {@link EventTime}.
*/
public void onInterruptedByAd(EventTime eventTime) {
isInterruptedByAd = true; isInterruptedByAd = true;
isSeeking = false; isSeeking = false;
maybeUpdatePlaybackState(eventTime, /* belongsToPlayback= */ true);
} }
/** /**
* Notifies the tracker that the current playback has finished. * Notifies the tracker that the current playback has finished.
* *
* @param eventTime The {@link EventTime}. Not guaranteed to belong to the current playback. * @param eventTime The {@link EventTime}. Does not belong to this playback.
* @param automaticTransition Whether the playback finished because of an automatic transition
* to the next playback item.
*/ */
public void onFinished(EventTime eventTime) { public void onFinished(EventTime eventTime, boolean automaticTransition) {
isFinished = true; // Simulate state change to ENDED to record natural ending of playback.
maybeUpdatePlaybackState(eventTime, /* belongsToPlayback= */ false); @PlaybackState
} int finalPlaybackState =
currentPlaybackState == PlaybackStats.PLAYBACK_STATE_ENDED || automaticTransition
/** ? PlaybackStats.PLAYBACK_STATE_ENDED
* Notifies the tracker that the track selection for the current playback changed. : PlaybackStats.PLAYBACK_STATE_ABANDONED;
* maybeUpdateMediaTimeHistory(eventTime.realtimeMs, /* mediaTimeMs= */ C.TIME_UNSET);
* @param eventTime The {@link EventTime}.
* @param trackSelections The new {@link TrackSelectionArray}.
*/
public void onTracksChanged(EventTime eventTime, TrackSelectionArray trackSelections) {
boolean videoEnabled = false;
boolean audioEnabled = false;
for (TrackSelection trackSelection : trackSelections.getAll()) {
if (trackSelection != null && trackSelection.length() > 0) {
int trackType = MimeTypes.getTrackType(trackSelection.getFormat(0).sampleMimeType);
if (trackType == C.TRACK_TYPE_VIDEO) {
videoEnabled = true;
} else if (trackType == C.TRACK_TYPE_AUDIO) {
audioEnabled = true;
}
}
}
if (!videoEnabled) {
maybeUpdateVideoFormat(eventTime, /* newFormat= */ null);
}
if (!audioEnabled) {
maybeUpdateAudioFormat(eventTime, /* newFormat= */ null);
}
}
/**
* Notifies the tracker that a format being read by the renderers for the current playback
* changed.
*
* @param eventTime The {@link EventTime}.
* @param mediaLoadData The {@link MediaLoadData} describing the format change.
*/
public void onDownstreamFormatChanged(EventTime eventTime, MediaLoadData mediaLoadData) {
if (mediaLoadData.trackType == C.TRACK_TYPE_VIDEO
|| mediaLoadData.trackType == C.TRACK_TYPE_DEFAULT) {
maybeUpdateVideoFormat(eventTime, mediaLoadData.trackFormat);
} else if (mediaLoadData.trackType == C.TRACK_TYPE_AUDIO) {
maybeUpdateAudioFormat(eventTime, mediaLoadData.trackFormat);
}
}
/**
* Notifies the tracker that the video size for the current playback changed.
*
* @param eventTime The {@link EventTime}.
* @param width The video width in pixels.
* @param height The video height in pixels.
*/
public void onVideoSizeChanged(EventTime eventTime, int width, int height) {
if (currentVideoFormat != null && currentVideoFormat.height == Format.NO_VALUE) {
Format formatWithHeight =
currentVideoFormat.buildUpon().setWidth(width).setHeight(height).build();
maybeUpdateVideoFormat(eventTime, formatWithHeight);
}
}
/**
* Notifies the tracker of a playback speed change, including all playback speed changes while
* the playback is not in the foreground.
*
* @param eventTime The {@link EventTime}.
* @param playbackSpeed The new playback speed.
*/
public void onPlaybackSpeedChanged(EventTime eventTime, float playbackSpeed) {
maybeUpdateMediaTimeHistory(eventTime.realtimeMs, eventTime.eventPlaybackPositionMs);
maybeRecordVideoFormatTime(eventTime.realtimeMs); maybeRecordVideoFormatTime(eventTime.realtimeMs);
maybeRecordAudioFormatTime(eventTime.realtimeMs); maybeRecordAudioFormatTime(eventTime.realtimeMs);
currentPlaybackSpeed = playbackSpeed; updatePlaybackState(finalPlaybackState, eventTime);
}
/** Notifies the builder of an audio underrun for the current playback. */
public void onAudioUnderrun() {
audioUnderruns++;
} }
/** /**
* Notifies the tracker of dropped video frames for the current playback. * Notifies the tracker of new events.
* *
* @param droppedFrames The number of dropped video frames. * @param player The {@link Player}.
* @param eventTime The {@link EventTime} of the events.
* @param belongsToPlayback Whether the {@code eventTime} belongs to this playback.
* @param seeked Whether a seek occurred.
* @param positionDiscontinuity Whether a position discontinuity occurred for this playback.
* @param droppedFrameCount The number of newly dropped frames for this playback.
* @param hasAudioUnderun Whether a new audio underrun occurred for this playback.
* @param startedLoading Whether this playback started loading.
* @param fatalError A fatal error for this playback, or null.
* @param nonFatalException A non-fatal exception for this playback, or null.
* @param bandwidthTimeMs The time in milliseconds spent loading for this playback.
* @param bandwidthBytes The number of bytes loaded for this playback.
* @param videoFormat A reported downstream video format for this playback, or null.
* @param audioFormat A reported downstream audio format for this playback, or null.
* @param videoHeight The reported video height for this playback, or {@link Format#NO_VALUE}.
* @param videoWidth The reported video width for this playback, or {@link Format#NO_VALUE}.
*/ */
public void onDroppedVideoFrames(int droppedFrames) { public void onEvents(
this.droppedFrames += droppedFrames; Player player,
} EventTime eventTime,
boolean belongsToPlayback,
boolean seeked,
boolean positionDiscontinuity,
int droppedFrameCount,
boolean hasAudioUnderun,
boolean startedLoading,
@Nullable ExoPlaybackException fatalError,
@Nullable Exception nonFatalException,
long bandwidthTimeMs,
long bandwidthBytes,
@Nullable Format videoFormat,
@Nullable Format audioFormat,
int videoHeight,
int videoWidth) {
if (seeked) {
isSeeking = true;
}
if (player.getPlaybackState() != Player.STATE_BUFFERING) {
isSeeking = false;
}
int playerPlaybackState = player.getPlaybackState();
if (playerPlaybackState == Player.STATE_IDLE
|| playerPlaybackState == Player.STATE_ENDED
|| positionDiscontinuity) {
isInterruptedByAd = false;
}
if (fatalError != null) {
hasFatalError = true;
fatalErrorCount++;
if (keepHistory) {
fatalErrorHistory.add(new EventTimeAndException(eventTime, fatalError));
}
} else if (player.getPlayerError() == null) {
hasFatalError = false;
}
if (isForeground && !isInterruptedByAd) {
boolean videoEnabled = false;
boolean audioEnabled = false;
for (TrackSelection trackSelection : player.getCurrentTrackSelections().getAll()) {
if (trackSelection != null && trackSelection.length() > 0) {
int trackType = MimeTypes.getTrackType(trackSelection.getFormat(0).sampleMimeType);
if (trackType == C.TRACK_TYPE_VIDEO) {
videoEnabled = true;
} else if (trackType == C.TRACK_TYPE_AUDIO) {
audioEnabled = true;
}
}
}
if (!videoEnabled) {
maybeUpdateVideoFormat(eventTime, /* newFormat= */ null);
}
if (!audioEnabled) {
maybeUpdateAudioFormat(eventTime, /* newFormat= */ null);
}
}
if (videoFormat != null) {
maybeUpdateVideoFormat(eventTime, videoFormat);
}
if (audioFormat != null) {
maybeUpdateAudioFormat(eventTime, audioFormat);
}
if (currentVideoFormat != null
&& currentVideoFormat.height == Format.NO_VALUE
&& videoHeight != Format.NO_VALUE) {
Format formatWithHeightAndWidth =
currentVideoFormat.buildUpon().setWidth(videoWidth).setHeight(videoHeight).build();
maybeUpdateVideoFormat(eventTime, formatWithHeightAndWidth);
}
if (startedLoading) {
this.startedLoading = true;
}
if (hasAudioUnderun) {
audioUnderruns++;
}
this.droppedFrames += droppedFrameCount;
this.bandwidthTimeMs += bandwidthTimeMs;
this.bandwidthBytes += bandwidthBytes;
if (nonFatalException != null) {
nonFatalErrorCount++;
if (keepHistory) {
nonFatalErrorHistory.add(new EventTimeAndException(eventTime, nonFatalException));
}
}
/** @PlaybackState int newPlaybackState = resolveNewPlaybackState(player);
* Notifies the tracker of bandwidth measurement data for the current playback. float newPlaybackSpeed = player.getPlaybackParameters().speed;
* if (currentPlaybackState != newPlaybackState || currentPlaybackSpeed != newPlaybackSpeed) {
* @param timeMs The time for which bandwidth measurement data is available, in milliseconds. maybeUpdateMediaTimeHistory(
* @param bytes The bytes transferred during {@code timeMs}. eventTime.realtimeMs,
*/ belongsToPlayback ? eventTime.eventPlaybackPositionMs : C.TIME_UNSET);
public void onBandwidthData(long timeMs, long bytes) { maybeRecordVideoFormatTime(eventTime.realtimeMs);
bandwidthTimeMs += timeMs; maybeRecordAudioFormatTime(eventTime.realtimeMs);
bandwidthBytes += bytes; }
} currentPlaybackSpeed = newPlaybackSpeed;
if (currentPlaybackState != newPlaybackState) {
/** updatePlaybackState(newPlaybackState, eventTime);
* Notifies the tracker of a non-fatal error in the current playback.
*
* @param eventTime The {@link EventTime}.
* @param error The error.
*/
public void onNonFatalError(EventTime eventTime, Exception error) {
nonFatalErrorCount++;
if (keepHistory) {
nonFatalErrorHistory.add(new EventTimeAndException(eventTime, error));
} }
} }
@ -860,13 +694,8 @@ public final class PlaybackStatsListener
nonFatalErrorHistory); nonFatalErrorHistory);
} }
private void maybeUpdatePlaybackState(EventTime eventTime, boolean belongsToPlayback) { private void updatePlaybackState(@PlaybackState int newPlaybackState, EventTime eventTime) {
@PlaybackState int newPlaybackState = resolveNewPlaybackState();
if (newPlaybackState == currentPlaybackState) {
return;
}
Assertions.checkArgument(eventTime.realtimeMs >= currentPlaybackStateStartTimeMs); Assertions.checkArgument(eventTime.realtimeMs >= currentPlaybackStateStartTimeMs);
long stateDurationMs = eventTime.realtimeMs - currentPlaybackStateStartTimeMs; long stateDurationMs = eventTime.realtimeMs - currentPlaybackStateStartTimeMs;
playbackStateDurationsMs[currentPlaybackState] += stateDurationMs; playbackStateDurationsMs[currentPlaybackState] += stateDurationMs;
if (firstReportedTimeMs == C.TIME_UNSET) { if (firstReportedTimeMs == C.TIME_UNSET) {
@ -890,13 +719,7 @@ public final class PlaybackStatsListener
&& newPlaybackState == PlaybackStats.PLAYBACK_STATE_PAUSED_BUFFERING) { && newPlaybackState == PlaybackStats.PLAYBACK_STATE_PAUSED_BUFFERING) {
pauseBufferCount++; pauseBufferCount++;
} }
maybeUpdateMediaTimeHistory(
eventTime.realtimeMs,
/* mediaTimeMs= */ belongsToPlayback ? eventTime.eventPlaybackPositionMs : C.TIME_UNSET);
maybeUpdateMaxRebufferTimeMs(eventTime.realtimeMs); maybeUpdateMaxRebufferTimeMs(eventTime.realtimeMs);
maybeRecordVideoFormatTime(eventTime.realtimeMs);
maybeRecordAudioFormatTime(eventTime.realtimeMs);
currentPlaybackState = newPlaybackState; currentPlaybackState = newPlaybackState;
currentPlaybackStateStartTimeMs = eventTime.realtimeMs; currentPlaybackStateStartTimeMs = eventTime.realtimeMs;
@ -905,13 +728,9 @@ public final class PlaybackStatsListener
} }
} }
private @PlaybackState int resolveNewPlaybackState() { private @PlaybackState int resolveNewPlaybackState(Player player) {
if (isFinished) { @Player.State int playerPlaybackState = player.getPlaybackState();
// Keep VIDEO_STATE_ENDED if playback naturally ended (or progressed to next item). if (isSeeking && isForeground) {
return currentPlaybackState == PlaybackStats.PLAYBACK_STATE_ENDED
? PlaybackStats.PLAYBACK_STATE_ENDED
: PlaybackStats.PLAYBACK_STATE_ABANDONED;
} else if (isSeeking && isForeground) {
// Seeking takes precedence over errors such that we report a seek while in error state. // Seeking takes precedence over errors such that we report a seek while in error state.
return PlaybackStats.PLAYBACK_STATE_SEEKING; return PlaybackStats.PLAYBACK_STATE_SEEKING;
} else if (hasFatalError) { } else if (hasFatalError) {
@ -932,17 +751,17 @@ public final class PlaybackStatsListener
|| currentPlaybackState == PlaybackStats.PLAYBACK_STATE_INTERRUPTED_BY_AD) { || currentPlaybackState == PlaybackStats.PLAYBACK_STATE_INTERRUPTED_BY_AD) {
return PlaybackStats.PLAYBACK_STATE_JOINING_FOREGROUND; return PlaybackStats.PLAYBACK_STATE_JOINING_FOREGROUND;
} }
if (!playWhenReady) { if (!player.getPlayWhenReady()) {
return PlaybackStats.PLAYBACK_STATE_PAUSED_BUFFERING; return PlaybackStats.PLAYBACK_STATE_PAUSED_BUFFERING;
} }
return isSuppressed return player.getPlaybackSuppressionReason() != Player.PLAYBACK_SUPPRESSION_REASON_NONE
? PlaybackStats.PLAYBACK_STATE_SUPPRESSED_BUFFERING ? PlaybackStats.PLAYBACK_STATE_SUPPRESSED_BUFFERING
: PlaybackStats.PLAYBACK_STATE_BUFFERING; : PlaybackStats.PLAYBACK_STATE_BUFFERING;
} else if (playerPlaybackState == Player.STATE_READY) { } else if (playerPlaybackState == Player.STATE_READY) {
if (!playWhenReady) { if (!player.getPlayWhenReady()) {
return PlaybackStats.PLAYBACK_STATE_PAUSED; return PlaybackStats.PLAYBACK_STATE_PAUSED;
} }
return isSuppressed return player.getPlaybackSuppressionReason() != Player.PLAYBACK_SUPPRESSION_REASON_NONE
? PlaybackStats.PLAYBACK_STATE_SUPPRESSED ? PlaybackStats.PLAYBACK_STATE_SUPPRESSED
: PlaybackStats.PLAYBACK_STATE_PLAYING; : PlaybackStats.PLAYBACK_STATE_PLAYING;
} else if (playerPlaybackState == Player.STATE_IDLE } else if (playerPlaybackState == Player.STATE_IDLE

View File

@ -17,71 +17,60 @@ package com.google.android.exoplayer2.analytics;
import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth.assertThat;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.robolectric.shadows.ShadowLooper.runMainLooperToNextTask;
import android.os.SystemClock;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.test.core.app.ApplicationProvider;
import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.ext.junit.runners.AndroidJUnit4;
import com.google.android.exoplayer2.MediaItem;
import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.PlaybackParameters;
import com.google.android.exoplayer2.Player; import com.google.android.exoplayer2.Player;
import com.google.android.exoplayer2.Timeline; import com.google.android.exoplayer2.SimpleExoPlayer;
import com.google.android.exoplayer2.robolectric.TestPlayerRunHelper;
import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.MediaSource;
import com.google.android.exoplayer2.testutil.FakeMediaSource;
import com.google.android.exoplayer2.testutil.FakeTimeline; import com.google.android.exoplayer2.testutil.FakeTimeline;
import com.google.android.exoplayer2.testutil.TestExoPlayerBuilder;
import com.google.common.collect.ImmutableList;
import java.util.stream.Collectors;
import org.junit.After;
import org.junit.Before;
import org.junit.Test; import org.junit.Test;
import org.junit.runner.RunWith; import org.junit.runner.RunWith;
import org.mockito.ArgumentCaptor;
/** Unit test for {@link PlaybackStatsListener}. */ /** Unit test for {@link PlaybackStatsListener}. */
@RunWith(AndroidJUnit4.class) @RunWith(AndroidJUnit4.class)
public final class PlaybackStatsListenerTest { public final class PlaybackStatsListenerTest {
private static final AnalyticsListener.EventTime EMPTY_TIMELINE_EVENT_TIME = private SimpleExoPlayer player;
new AnalyticsListener.EventTime(
/* realtimeMs= */ 500, @Before
Timeline.EMPTY, public void setUp() {
/* windowIndex= */ 0, player = new TestExoPlayerBuilder(ApplicationProvider.getApplicationContext()).build();
/* mediaPeriodId= */ null, }
/* eventPlaybackPositionMs= */ 0,
/* currentTimeline= */ Timeline.EMPTY, @After
/* currentWindowIndex= */ 0, public void tearDown() {
/* currentMediaPeriodId= */ null, player.release();
/* currentPlaybackPositionMs= */ 0, }
/* totalBufferedDurationMs= */ 0);
private static final Timeline TEST_TIMELINE = new FakeTimeline();
private static final MediaSource.MediaPeriodId TEST_MEDIA_PERIOD_ID =
new MediaSource.MediaPeriodId(
TEST_TIMELINE.getPeriod(/* periodIndex= */ 0, new Timeline.Period(), /* setIds= */ true)
.uid,
/* windowSequenceNumber= */ 42);
private static final AnalyticsListener.EventTime TEST_EVENT_TIME =
new AnalyticsListener.EventTime(
/* realtimeMs= */ 500,
TEST_TIMELINE,
/* windowIndex= */ 0,
TEST_MEDIA_PERIOD_ID,
/* eventPlaybackPositionMs= */ 123,
TEST_TIMELINE,
/* currentWindowIndex= */ 0,
TEST_MEDIA_PERIOD_ID,
/* currentPlaybackPositionMs= */ 123,
/* totalBufferedDurationMs= */ 456);
@Test @Test
public void events_duringInitialIdleState_dontCreateNewPlaybackStats() { public void events_duringInitialIdleState_dontCreateNewPlaybackStats() {
PlaybackStatsListener playbackStatsListener = PlaybackStatsListener playbackStatsListener =
new PlaybackStatsListener(/* keepHistory= */ true, /* callback= */ null); new PlaybackStatsListener(/* keepHistory= */ true, /* callback= */ null);
player.addAnalyticsListener(playbackStatsListener);
playbackStatsListener.onPositionDiscontinuity( player.seekTo(/* positionMs= */ 1234);
EMPTY_TIMELINE_EVENT_TIME, Player.DISCONTINUITY_REASON_SEEK); runMainLooperToNextTask();
playbackStatsListener.onPlaybackParametersChanged( player.setPlaybackParameters(new PlaybackParameters(/* speed= */ 2f));
EMPTY_TIMELINE_EVENT_TIME, new PlaybackParameters(/* speed= */ 2.0f)); runMainLooperToNextTask();
playbackStatsListener.onPlayWhenReadyChanged( player.play();
EMPTY_TIMELINE_EVENT_TIME, runMainLooperToNextTask();
/* playWhenReady= */ true,
Player.PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST);
assertThat(playbackStatsListener.getPlaybackStats()).isNull(); assertThat(playbackStatsListener.getPlaybackStats()).isNull();
} }
@ -90,8 +79,10 @@ public final class PlaybackStatsListenerTest {
public void stateChangeEvent_toNonIdle_createsInitialPlaybackStats() { public void stateChangeEvent_toNonIdle_createsInitialPlaybackStats() {
PlaybackStatsListener playbackStatsListener = PlaybackStatsListener playbackStatsListener =
new PlaybackStatsListener(/* keepHistory= */ true, /* callback= */ null); new PlaybackStatsListener(/* keepHistory= */ true, /* callback= */ null);
player.addAnalyticsListener(playbackStatsListener);
playbackStatsListener.onPlaybackStateChanged(EMPTY_TIMELINE_EVENT_TIME, Player.STATE_BUFFERING); player.prepare();
runMainLooperToNextTask();
assertThat(playbackStatsListener.getPlaybackStats()).isNotNull(); assertThat(playbackStatsListener.getPlaybackStats()).isNotNull();
} }
@ -100,21 +91,25 @@ public final class PlaybackStatsListenerTest {
public void timelineChangeEvent_toNonEmpty_createsInitialPlaybackStats() { public void timelineChangeEvent_toNonEmpty_createsInitialPlaybackStats() {
PlaybackStatsListener playbackStatsListener = PlaybackStatsListener playbackStatsListener =
new PlaybackStatsListener(/* keepHistory= */ true, /* callback= */ null); new PlaybackStatsListener(/* keepHistory= */ true, /* callback= */ null);
player.addAnalyticsListener(playbackStatsListener);
playbackStatsListener.onTimelineChanged( player.setMediaItem(MediaItem.fromUri("http://test.org"));
TEST_EVENT_TIME, Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); runMainLooperToNextTask();
assertThat(playbackStatsListener.getPlaybackStats()).isNotNull(); assertThat(playbackStatsListener.getPlaybackStats()).isNotNull();
} }
@Test @Test
public void playback_withKeepHistory_updatesStats() { public void playback_withKeepHistory_updatesStats() throws Exception {
PlaybackStatsListener playbackStatsListener = PlaybackStatsListener playbackStatsListener =
new PlaybackStatsListener(/* keepHistory= */ true, /* callback= */ null); new PlaybackStatsListener(/* keepHistory= */ true, /* callback= */ null);
player.addAnalyticsListener(playbackStatsListener);
playbackStatsListener.onPlaybackStateChanged(TEST_EVENT_TIME, Player.STATE_BUFFERING); player.setMediaSource(new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)));
playbackStatsListener.onPlaybackStateChanged(TEST_EVENT_TIME, Player.STATE_READY); player.prepare();
playbackStatsListener.onPlaybackStateChanged(TEST_EVENT_TIME, Player.STATE_ENDED); player.play();
TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED);
runMainLooperToNextTask();
@Nullable PlaybackStats playbackStats = playbackStatsListener.getPlaybackStats(); @Nullable PlaybackStats playbackStats = playbackStatsListener.getPlaybackStats();
assertThat(playbackStats).isNotNull(); assertThat(playbackStats).isNotNull();
@ -122,13 +117,16 @@ public final class PlaybackStatsListenerTest {
} }
@Test @Test
public void playback_withoutKeepHistory_updatesStats() { public void playback_withoutKeepHistory_updatesStats() throws Exception {
PlaybackStatsListener playbackStatsListener = PlaybackStatsListener playbackStatsListener =
new PlaybackStatsListener(/* keepHistory= */ false, /* callback= */ null); new PlaybackStatsListener(/* keepHistory= */ false, /* callback= */ null);
player.addAnalyticsListener(playbackStatsListener);
playbackStatsListener.onPlaybackStateChanged(TEST_EVENT_TIME, Player.STATE_BUFFERING); player.setMediaSource(new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)));
playbackStatsListener.onPlaybackStateChanged(TEST_EVENT_TIME, Player.STATE_READY); player.prepare();
playbackStatsListener.onPlaybackStateChanged(TEST_EVENT_TIME, Player.STATE_ENDED); player.play();
TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_ENDED);
runMainLooperToNextTask();
@Nullable PlaybackStats playbackStats = playbackStatsListener.getPlaybackStats(); @Nullable PlaybackStats playbackStats = playbackStatsListener.getPlaybackStats();
assertThat(playbackStats).isNotNull(); assertThat(playbackStats).isNotNull();
@ -140,53 +138,45 @@ public final class PlaybackStatsListenerTest {
PlaybackStatsListener.Callback callback = mock(PlaybackStatsListener.Callback.class); PlaybackStatsListener.Callback callback = mock(PlaybackStatsListener.Callback.class);
PlaybackStatsListener playbackStatsListener = PlaybackStatsListener playbackStatsListener =
new PlaybackStatsListener(/* keepHistory= */ true, callback); new PlaybackStatsListener(/* keepHistory= */ true, callback);
player.addAnalyticsListener(playbackStatsListener);
// Create session with an event and finish it by simulating removal from playlist. // Create session with some events and finish it by removing it from the playlist.
playbackStatsListener.onPlaybackStateChanged(TEST_EVENT_TIME, Player.STATE_BUFFERING); player.setMediaSource(new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1)));
player.prepare();
runMainLooperToNextTask();
verify(callback, never()).onPlaybackStatsReady(any(), any()); verify(callback, never()).onPlaybackStatsReady(any(), any());
playbackStatsListener.onTimelineChanged( player.clearMediaItems();
EMPTY_TIMELINE_EVENT_TIME, Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED); runMainLooperToNextTask();
verify(callback).onPlaybackStatsReady(eq(TEST_EVENT_TIME), any()); verify(callback).onPlaybackStatsReady(any(), any());
} }
@Test @Test
public void finishAllSessions_callsAllPendingCallbacks() { public void finishAllSessions_callsAllPendingCallbacks() throws Exception {
AnalyticsListener.EventTime eventTimeWindow0 =
new AnalyticsListener.EventTime(
/* realtimeMs= */ 0,
Timeline.EMPTY,
/* windowIndex= */ 0,
/* mediaPeriodId= */ null,
/* eventPlaybackPositionMs= */ 0,
Timeline.EMPTY,
/* currentWindowIndex= */ 0,
/* currentMediaPeriodId= */ null,
/* currentPlaybackPositionMs= */ 0,
/* totalBufferedDurationMs= */ 0);
AnalyticsListener.EventTime eventTimeWindow1 =
new AnalyticsListener.EventTime(
/* realtimeMs= */ 0,
Timeline.EMPTY,
/* windowIndex= */ 1,
/* mediaPeriodId= */ null,
/* eventPlaybackPositionMs= */ 0,
Timeline.EMPTY,
/* currentWindowIndex= */ 1,
/* currentMediaPeriodId= */ null,
/* currentPlaybackPositionMs= */ 0,
/* totalBufferedDurationMs= */ 0);
PlaybackStatsListener.Callback callback = mock(PlaybackStatsListener.Callback.class); PlaybackStatsListener.Callback callback = mock(PlaybackStatsListener.Callback.class);
PlaybackStatsListener playbackStatsListener = PlaybackStatsListener playbackStatsListener =
new PlaybackStatsListener(/* keepHistory= */ true, callback); new PlaybackStatsListener(/* keepHistory= */ true, callback);
playbackStatsListener.onPlaybackStateChanged(eventTimeWindow0, Player.STATE_BUFFERING); player.addAnalyticsListener(playbackStatsListener);
playbackStatsListener.onPlaybackStateChanged(eventTimeWindow1, Player.STATE_BUFFERING);
MediaSource mediaSource = new FakeMediaSource(new FakeTimeline(/* windowCount= */ 1));
player.setMediaSources(ImmutableList.of(mediaSource, mediaSource));
player.prepare();
TestPlayerRunHelper.runUntilPlaybackState(player, Player.STATE_READY);
// Play close to the end of the first item to ensure the second session is already created, but
// the first one isn't finished yet.
TestPlayerRunHelper.playUntilPosition(
player, /* windowIndex= */ 0, /* positionMs= */ player.getDuration());
runMainLooperToNextTask();
playbackStatsListener.finishAllSessions(); playbackStatsListener.finishAllSessions();
verify(callback, times(2)).onPlaybackStatsReady(any(), any()); ArgumentCaptor<AnalyticsListener.EventTime> eventTimeCaptor =
verify(callback).onPlaybackStatsReady(eq(eventTimeWindow0), any()); ArgumentCaptor.forClass(AnalyticsListener.EventTime.class);
verify(callback).onPlaybackStatsReady(eq(eventTimeWindow1), any()); verify(callback, times(2)).onPlaybackStatsReady(eventTimeCaptor.capture(), any());
assertThat(
eventTimeCaptor.getAllValues().stream()
.map(eventTime -> eventTime.windowIndex)
.collect(Collectors.toList()))
.containsExactly(0, 1);
} }
@Test @Test
@ -194,14 +184,17 @@ public final class PlaybackStatsListenerTest {
PlaybackStatsListener.Callback callback = mock(PlaybackStatsListener.Callback.class); PlaybackStatsListener.Callback callback = mock(PlaybackStatsListener.Callback.class);
PlaybackStatsListener playbackStatsListener = PlaybackStatsListener playbackStatsListener =
new PlaybackStatsListener(/* keepHistory= */ true, callback); new PlaybackStatsListener(/* keepHistory= */ true, callback);
playbackStatsListener.onPlaybackStateChanged(TEST_EVENT_TIME, Player.STATE_BUFFERING); player.addAnalyticsListener(playbackStatsListener);
SystemClock.setCurrentTimeMillis(TEST_EVENT_TIME.realtimeMs + 100);
player.setMediaItem(MediaItem.fromUri("http://test.org"));
runMainLooperToNextTask();
playbackStatsListener.finishAllSessions(); playbackStatsListener.finishAllSessions();
// Simulate removing the playback item to ensure the session would finish if it hadn't already.
playbackStatsListener.onTimelineChanged(
EMPTY_TIMELINE_EVENT_TIME, Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED);
// Simulate removing the playback item to ensure the session would finish if it hadn't already.
player.clearMediaItems();
runMainLooperToNextTask();
// Verify the callback was called once only.
verify(callback).onPlaybackStatsReady(any(), any()); verify(callback).onPlaybackStatsReady(any(), any());
} }
} }