| /* |
| * Copyright (C) 2019 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.analytics; |
| |
| import android.os.SystemClock; |
| import androidx.annotation.Nullable; |
| import com.google.android.exoplayer2.C; |
| import com.google.android.exoplayer2.ExoPlaybackException; |
| import com.google.android.exoplayer2.Format; |
| import com.google.android.exoplayer2.Player; |
| import com.google.android.exoplayer2.Timeline; |
| import com.google.android.exoplayer2.Timeline.Period; |
| import com.google.android.exoplayer2.analytics.PlaybackStats.EventTimeAndException; |
| import com.google.android.exoplayer2.analytics.PlaybackStats.EventTimeAndFormat; |
| import com.google.android.exoplayer2.analytics.PlaybackStats.EventTimeAndPlaybackState; |
| import com.google.android.exoplayer2.analytics.PlaybackStats.PlaybackState; |
| import com.google.android.exoplayer2.source.LoadEventInfo; |
| import com.google.android.exoplayer2.source.MediaLoadData; |
| 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.TrackSelectionArray; |
| import com.google.android.exoplayer2.util.Assertions; |
| import com.google.android.exoplayer2.util.MimeTypes; |
| import com.google.android.exoplayer2.util.Util; |
| import java.io.IOException; |
| import java.util.ArrayList; |
| import java.util.Arrays; |
| import java.util.Collections; |
| import java.util.HashMap; |
| import java.util.List; |
| import java.util.Map; |
| |
| /** |
| * {@link AnalyticsListener} to gather {@link PlaybackStats} from the player. |
| * |
| * <p>For accurate measurements, the listener should be added to the player before loading media, |
| * i.e., {@link Player#getPlaybackState()} should be {@link Player#STATE_IDLE}. |
| * |
| * <p>Playback stats are gathered separately for each playback session, i.e. each window in the |
| * {@link Timeline} and each single ad. |
| */ |
| public final class PlaybackStatsListener |
| implements AnalyticsListener, PlaybackSessionManager.Listener { |
| |
| /** A listener for {@link PlaybackStats} updates. */ |
| public interface Callback { |
| |
| /** |
| * Called when a playback session ends and its {@link PlaybackStats} are ready. |
| * |
| * @param eventTime The {@link EventTime} at which the playback session started. Can be used to |
| * identify the playback session. |
| * @param playbackStats The {@link PlaybackStats} for the ended playback session. |
| */ |
| void onPlaybackStatsReady(EventTime eventTime, PlaybackStats playbackStats); |
| } |
| |
| private final PlaybackSessionManager sessionManager; |
| private final Map<String, PlaybackStatsTracker> playbackStatsTrackers; |
| private final Map<String, EventTime> sessionStartEventTimes; |
| @Nullable private final Callback callback; |
| private final boolean keepHistory; |
| private final Period period; |
| |
| private PlaybackStats finishedPlaybackStats; |
| @Nullable private String activeContentPlayback; |
| @Nullable private String activeAdPlayback; |
| private boolean playWhenReady; |
| @Player.State private int playbackState; |
| private boolean isSuppressed; |
| private float playbackSpeed; |
| private boolean onSeekStartedCalled; |
| |
| /** |
| * Creates listener for playback stats. |
| * |
| * @param keepHistory Whether the reported {@link PlaybackStats} should keep the full history of |
| * events. |
| * @param callback An optional callback for finished {@link PlaybackStats}. |
| */ |
| public PlaybackStatsListener(boolean keepHistory, @Nullable Callback callback) { |
| this.callback = callback; |
| this.keepHistory = keepHistory; |
| sessionManager = new DefaultPlaybackSessionManager(); |
| playbackStatsTrackers = new HashMap<>(); |
| sessionStartEventTimes = new HashMap<>(); |
| finishedPlaybackStats = PlaybackStats.EMPTY; |
| playWhenReady = false; |
| playbackState = Player.STATE_IDLE; |
| playbackSpeed = 1f; |
| period = new Period(); |
| sessionManager.setListener(this); |
| } |
| |
| /** |
| * Returns the combined {@link PlaybackStats} for all playback sessions this listener was and is |
| * listening to. |
| * |
| * <p>Note that these {@link PlaybackStats} will not contain the full history of events. |
| * |
| * @return The combined {@link PlaybackStats} for all playback sessions. |
| */ |
| public PlaybackStats getCombinedPlaybackStats() { |
| PlaybackStats[] allPendingPlaybackStats = new PlaybackStats[playbackStatsTrackers.size() + 1]; |
| allPendingPlaybackStats[0] = finishedPlaybackStats; |
| int index = 1; |
| for (PlaybackStatsTracker tracker : playbackStatsTrackers.values()) { |
| allPendingPlaybackStats[index++] = tracker.build(/* isFinal= */ false); |
| } |
| return PlaybackStats.merge(allPendingPlaybackStats); |
| } |
| |
| /** |
| * Returns the {@link PlaybackStats} for the currently playback session, or null if no session is |
| * active. |
| * |
| * @return {@link PlaybackStats} for the current playback session. |
| */ |
| @Nullable |
| public PlaybackStats getPlaybackStats() { |
| @Nullable |
| PlaybackStatsTracker activeStatsTracker = |
| activeAdPlayback != null |
| ? playbackStatsTrackers.get(activeAdPlayback) |
| : activeContentPlayback != null |
| ? playbackStatsTrackers.get(activeContentPlayback) |
| : null; |
| return activeStatsTracker == null ? null : activeStatsTracker.build(/* isFinal= */ false); |
| } |
| |
| /** |
| * Finishes all pending playback sessions. Should be called when the listener is removed from the |
| * player or when the player is released. |
| */ |
| public void finishAllSessions() { |
| // TODO: Add AnalyticsListener.onAttachedToPlayer and onDetachedFromPlayer to auto-release with |
| // an actual EventTime. Should also simplify other cases where the listener needs to be released |
| // separately from the player. |
| EventTime dummyEventTime = |
| new EventTime( |
| SystemClock.elapsedRealtime(), |
| Timeline.EMPTY, |
| /* windowIndex= */ 0, |
| /* mediaPeriodId= */ null, |
| /* eventPlaybackPositionMs= */ 0, |
| /* currentPlaybackPositionMs= */ 0, |
| /* totalBufferedDurationMs= */ 0); |
| sessionManager.finishAllSessions(dummyEventTime); |
| } |
| |
| // PlaybackSessionManager.Listener implementation. |
| |
| @Override |
| public void onSessionCreated(EventTime eventTime, String session) { |
| 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); |
| sessionStartEventTimes.put(session, eventTime); |
| } |
| |
| @Override |
| public void onSessionActive(EventTime eventTime, String session) { |
| Assertions.checkNotNull(playbackStatsTrackers.get(session)).onForeground(eventTime); |
| if (eventTime.mediaPeriodId != null && eventTime.mediaPeriodId.isAd()) { |
| activeAdPlayback = session; |
| } else { |
| activeContentPlayback = session; |
| } |
| } |
| |
| @Override |
| public void onAdPlaybackStarted(EventTime eventTime, String contentSession, String adSession) { |
| Assertions.checkState(Assertions.checkNotNull(eventTime.mediaPeriodId).isAd()); |
| 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.currentPlaybackPositionMs, |
| eventTime.totalBufferedDurationMs); |
| Assertions.checkNotNull(playbackStatsTrackers.get(contentSession)) |
| .onInterruptedByAd(contentEventTime); |
| } |
| |
| @Override |
| public void onSessionFinished(EventTime eventTime, String session, boolean automaticTransition) { |
| if (session.equals(activeAdPlayback)) { |
| activeAdPlayback = null; |
| } else if (session.equals(activeContentPlayback)) { |
| activeContentPlayback = null; |
| } |
| PlaybackStatsTracker tracker = Assertions.checkNotNull(playbackStatsTrackers.remove(session)); |
| EventTime startEventTime = Assertions.checkNotNull(sessionStartEventTimes.remove(session)); |
| if (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); |
| finishedPlaybackStats = PlaybackStats.merge(finishedPlaybackStats, playbackStats); |
| if (callback != null) { |
| callback.onPlaybackStatsReady(startEventTime, playbackStats); |
| } |
| } |
| |
| // 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.handleTimelineUpdate(eventTime); |
| maybeAddSession(eventTime); |
| for (String session : playbackStatsTrackers.keySet()) { |
| if (sessionManager.belongsToSession(eventTime, session)) { |
| playbackStatsTrackers.get(session).onPositionDiscontinuity(eventTime, /* isSeek= */ false); |
| } |
| } |
| } |
| |
| @Override |
| public void onPositionDiscontinuity(EventTime eventTime, @Player.DiscontinuityReason int reason) { |
| sessionManager.handlePositionDiscontinuity(eventTime, reason); |
| maybeAddSession(eventTime); |
| 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 |
| public void onSeekStarted(EventTime eventTime) { |
| maybeAddSession(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 onPlaybackSpeedChanged(EventTime eventTime, float playbackSpeed) { |
| this.playbackSpeed = playbackSpeed; |
| 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 |
| public void onDroppedVideoFrames(EventTime eventTime, int droppedFrames, long elapsedMs) { |
| maybeAddSession(eventTime); |
| for (String session : playbackStatsTrackers.keySet()) { |
| if (sessionManager.belongsToSession(eventTime, session)) { |
| playbackStatsTrackers.get(session).onDroppedVideoFrames(droppedFrames); |
| } |
| } |
| } |
| |
| @Override |
| public void onLoadError( |
| EventTime eventTime, |
| LoadEventInfo loadEventInfo, |
| MediaLoadData mediaLoadData, |
| IOException error, |
| boolean wasCanceled) { |
| maybeAddSession(eventTime); |
| for (String session : playbackStatsTrackers.keySet()) { |
| if (sessionManager.belongsToSession(eventTime, session)) { |
| playbackStatsTrackers.get(session).onNonFatalError(eventTime, error); |
| } |
| } |
| } |
| |
| @Override |
| public void onDrmSessionManagerError(EventTime eventTime, Exception error) { |
| maybeAddSession(eventTime); |
| for (String session : playbackStatsTrackers.keySet()) { |
| if (sessionManager.belongsToSession(eventTime, session)) { |
| playbackStatsTrackers.get(session).onNonFatalError(eventTime, error); |
| } |
| } |
| } |
| |
| private void maybeAddSession(EventTime eventTime) { |
| boolean isCompletelyIdle = eventTime.timeline.isEmpty() && playbackState == Player.STATE_IDLE; |
| if (!isCompletelyIdle) { |
| sessionManager.updateSessions(eventTime); |
| } |
| } |
| |
| /** Tracker for playback stats of a single playback. */ |
| private static final class PlaybackStatsTracker { |
| |
| // Final stats. |
| private final boolean keepHistory; |
| private final long[] playbackStateDurationsMs; |
| private final List<EventTimeAndPlaybackState> playbackStateHistory; |
| private final List<long[]> mediaTimeHistory; |
| private final List<EventTimeAndFormat> videoFormatHistory; |
| private final List<EventTimeAndFormat> audioFormatHistory; |
| private final List<EventTimeAndException> fatalErrorHistory; |
| private final List<EventTimeAndException> nonFatalErrorHistory; |
| private final boolean isAd; |
| |
| private long firstReportedTimeMs; |
| private boolean hasBeenReady; |
| private boolean hasEnded; |
| private boolean isJoinTimeInvalid; |
| private int pauseCount; |
| private int pauseBufferCount; |
| private int seekCount; |
| private int rebufferCount; |
| private long maxRebufferTimeMs; |
| private int initialVideoFormatHeight; |
| private long initialVideoFormatBitrate; |
| private long initialAudioFormatBitrate; |
| private long videoFormatHeightTimeMs; |
| private long videoFormatHeightTimeProduct; |
| private long videoFormatBitrateTimeMs; |
| private long videoFormatBitrateTimeProduct; |
| private long audioFormatTimeMs; |
| private long audioFormatBitrateTimeProduct; |
| private long bandwidthTimeMs; |
| private long bandwidthBytes; |
| private long droppedFrames; |
| private long audioUnderruns; |
| private int fatalErrorCount; |
| private int nonFatalErrorCount; |
| |
| // Current player state tracking. |
| private @PlaybackState int currentPlaybackState; |
| private long currentPlaybackStateStartTimeMs; |
| private boolean isSeeking; |
| private boolean isForeground; |
| private boolean isInterruptedByAd; |
| private boolean isFinished; |
| private boolean playWhenReady; |
| @Player.State private int playerPlaybackState; |
| private boolean isSuppressed; |
| private boolean hasFatalError; |
| private boolean startedLoading; |
| private long lastRebufferStartTimeMs; |
| @Nullable private Format currentVideoFormat; |
| @Nullable private Format currentAudioFormat; |
| private long lastVideoFormatStartTimeMs; |
| private long lastAudioFormatStartTimeMs; |
| private float currentPlaybackSpeed; |
| |
| /** |
| * Creates a tracker for playback stats. |
| * |
| * @param keepHistory Whether to keep a full history of events. |
| * @param startTime The {@link EventTime} at which the playback stats start. |
| */ |
| public PlaybackStatsTracker(boolean keepHistory, EventTime startTime) { |
| this.keepHistory = keepHistory; |
| playbackStateDurationsMs = new long[PlaybackStats.PLAYBACK_STATE_COUNT]; |
| playbackStateHistory = keepHistory ? new ArrayList<>() : Collections.emptyList(); |
| mediaTimeHistory = keepHistory ? new ArrayList<>() : Collections.emptyList(); |
| videoFormatHistory = keepHistory ? new ArrayList<>() : Collections.emptyList(); |
| audioFormatHistory = keepHistory ? new ArrayList<>() : Collections.emptyList(); |
| fatalErrorHistory = keepHistory ? new ArrayList<>() : Collections.emptyList(); |
| nonFatalErrorHistory = keepHistory ? new ArrayList<>() : Collections.emptyList(); |
| currentPlaybackState = PlaybackStats.PLAYBACK_STATE_NOT_STARTED; |
| currentPlaybackStateStartTimeMs = startTime.realtimeMs; |
| playerPlaybackState = Player.STATE_IDLE; |
| firstReportedTimeMs = C.TIME_UNSET; |
| maxRebufferTimeMs = C.TIME_UNSET; |
| isAd = startTime.mediaPeriodId != null && startTime.mediaPeriodId.isAd(); |
| initialAudioFormatBitrate = C.LENGTH_UNSET; |
| initialVideoFormatBitrate = C.LENGTH_UNSET; |
| initialVideoFormatHeight = C.LENGTH_UNSET; |
| currentPlaybackSpeed = 1f; |
| } |
| |
| /** |
| * Notifies the tracker of a playback state change event, including all playback state changes |
| * 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; |
| maybeUpdatePlaybackState(eventTime, /* belongsToPlayback= */ true); |
| } |
| |
| /** |
| * Notifies the tracker that the current playback has been interrupted for ad playback. |
| * |
| * @param eventTime The {@link EventTime}. |
| */ |
| public void onInterruptedByAd(EventTime eventTime) { |
| isInterruptedByAd = true; |
| isSeeking = false; |
| maybeUpdatePlaybackState(eventTime, /* belongsToPlayback= */ true); |
| } |
| |
| /** |
| * Notifies the tracker that the current playback has finished. |
| * |
| * @param eventTime The {@link EventTime}. Not guaranteed to belong to the current playback. |
| */ |
| public void onFinished(EventTime eventTime) { |
| isFinished = true; |
| maybeUpdatePlaybackState(eventTime, /* belongsToPlayback= */ false); |
| } |
| |
| /** |
| * Notifies the tracker that the track selection for the current playback changed. |
| * |
| * @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); |
| maybeRecordAudioFormatTime(eventTime.realtimeMs); |
| currentPlaybackSpeed = playbackSpeed; |
| } |
| |
| /** 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. |
| * |
| * @param droppedFrames The number of dropped video frames. |
| */ |
| public void onDroppedVideoFrames(int droppedFrames) { |
| this.droppedFrames += droppedFrames; |
| } |
| |
| /** |
| * Notifies the tracker of bandwidth measurement data for the current playback. |
| * |
| * @param timeMs The time for which bandwidth measurement data is available, in milliseconds. |
| * @param bytes The bytes transferred during {@code timeMs}. |
| */ |
| public void onBandwidthData(long timeMs, long bytes) { |
| bandwidthTimeMs += timeMs; |
| bandwidthBytes += bytes; |
| } |
| |
| /** |
| * 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)); |
| } |
| } |
| |
| /** |
| * Builds the playback stats. |
| * |
| * @param isFinal Whether this is the final build and no further events are expected. |
| */ |
| public PlaybackStats build(boolean isFinal) { |
| long[] playbackStateDurationsMs = this.playbackStateDurationsMs; |
| List<long[]> mediaTimeHistory = this.mediaTimeHistory; |
| if (!isFinal) { |
| long buildTimeMs = SystemClock.elapsedRealtime(); |
| playbackStateDurationsMs = |
| Arrays.copyOf(this.playbackStateDurationsMs, PlaybackStats.PLAYBACK_STATE_COUNT); |
| long lastStateDurationMs = Math.max(0, buildTimeMs - currentPlaybackStateStartTimeMs); |
| playbackStateDurationsMs[currentPlaybackState] += lastStateDurationMs; |
| maybeUpdateMaxRebufferTimeMs(buildTimeMs); |
| maybeRecordVideoFormatTime(buildTimeMs); |
| maybeRecordAudioFormatTime(buildTimeMs); |
| mediaTimeHistory = new ArrayList<>(this.mediaTimeHistory); |
| if (keepHistory && currentPlaybackState == PlaybackStats.PLAYBACK_STATE_PLAYING) { |
| mediaTimeHistory.add(guessMediaTimeBasedOnElapsedRealtime(buildTimeMs)); |
| } |
| } |
| boolean isJoinTimeInvalid = this.isJoinTimeInvalid || !hasBeenReady; |
| long validJoinTimeMs = |
| isJoinTimeInvalid |
| ? C.TIME_UNSET |
| : playbackStateDurationsMs[PlaybackStats.PLAYBACK_STATE_JOINING_FOREGROUND]; |
| boolean hasBackgroundJoin = |
| playbackStateDurationsMs[PlaybackStats.PLAYBACK_STATE_JOINING_BACKGROUND] > 0; |
| List<EventTimeAndFormat> videoHistory = |
| isFinal ? videoFormatHistory : new ArrayList<>(videoFormatHistory); |
| List<EventTimeAndFormat> audioHistory = |
| isFinal ? audioFormatHistory : new ArrayList<>(audioFormatHistory); |
| return new PlaybackStats( |
| /* playbackCount= */ 1, |
| playbackStateDurationsMs, |
| isFinal ? playbackStateHistory : new ArrayList<>(playbackStateHistory), |
| mediaTimeHistory, |
| firstReportedTimeMs, |
| /* foregroundPlaybackCount= */ isForeground ? 1 : 0, |
| /* abandonedBeforeReadyCount= */ hasBeenReady ? 0 : 1, |
| /* endedCount= */ hasEnded ? 1 : 0, |
| /* backgroundJoiningCount= */ hasBackgroundJoin ? 1 : 0, |
| validJoinTimeMs, |
| /* validJoinTimeCount= */ isJoinTimeInvalid ? 0 : 1, |
| pauseCount, |
| pauseBufferCount, |
| seekCount, |
| rebufferCount, |
| maxRebufferTimeMs, |
| /* adPlaybackCount= */ isAd ? 1 : 0, |
| videoHistory, |
| audioHistory, |
| videoFormatHeightTimeMs, |
| videoFormatHeightTimeProduct, |
| videoFormatBitrateTimeMs, |
| videoFormatBitrateTimeProduct, |
| audioFormatTimeMs, |
| audioFormatBitrateTimeProduct, |
| /* initialVideoFormatHeightCount= */ initialVideoFormatHeight == C.LENGTH_UNSET ? 0 : 1, |
| /* initialVideoFormatBitrateCount= */ initialVideoFormatBitrate == C.LENGTH_UNSET ? 0 : 1, |
| initialVideoFormatHeight, |
| initialVideoFormatBitrate, |
| /* initialAudioFormatBitrateCount= */ initialAudioFormatBitrate == C.LENGTH_UNSET ? 0 : 1, |
| initialAudioFormatBitrate, |
| bandwidthTimeMs, |
| bandwidthBytes, |
| droppedFrames, |
| audioUnderruns, |
| /* fatalErrorPlaybackCount= */ fatalErrorCount > 0 ? 1 : 0, |
| fatalErrorCount, |
| nonFatalErrorCount, |
| fatalErrorHistory, |
| nonFatalErrorHistory); |
| } |
| |
| private void maybeUpdatePlaybackState(EventTime eventTime, boolean belongsToPlayback) { |
| @PlaybackState int newPlaybackState = resolveNewPlaybackState(); |
| if (newPlaybackState == currentPlaybackState) { |
| return; |
| } |
| Assertions.checkArgument(eventTime.realtimeMs >= currentPlaybackStateStartTimeMs); |
| |
| long stateDurationMs = eventTime.realtimeMs - currentPlaybackStateStartTimeMs; |
| playbackStateDurationsMs[currentPlaybackState] += stateDurationMs; |
| if (firstReportedTimeMs == C.TIME_UNSET) { |
| firstReportedTimeMs = eventTime.realtimeMs; |
| } |
| isJoinTimeInvalid |= isInvalidJoinTransition(currentPlaybackState, newPlaybackState); |
| hasBeenReady |= isReadyState(newPlaybackState); |
| hasEnded |= newPlaybackState == PlaybackStats.PLAYBACK_STATE_ENDED; |
| if (!isPausedState(currentPlaybackState) && isPausedState(newPlaybackState)) { |
| pauseCount++; |
| } |
| if (newPlaybackState == PlaybackStats.PLAYBACK_STATE_SEEKING) { |
| seekCount++; |
| } |
| if (!isRebufferingState(currentPlaybackState) && isRebufferingState(newPlaybackState)) { |
| rebufferCount++; |
| lastRebufferStartTimeMs = eventTime.realtimeMs; |
| } |
| if (isRebufferingState(currentPlaybackState) |
| && currentPlaybackState != PlaybackStats.PLAYBACK_STATE_PAUSED_BUFFERING |
| && newPlaybackState == PlaybackStats.PLAYBACK_STATE_PAUSED_BUFFERING) { |
| pauseBufferCount++; |
| } |
| |
| maybeUpdateMediaTimeHistory( |
| eventTime.realtimeMs, |
| /* mediaTimeMs= */ belongsToPlayback ? eventTime.eventPlaybackPositionMs : C.TIME_UNSET); |
| maybeUpdateMaxRebufferTimeMs(eventTime.realtimeMs); |
| maybeRecordVideoFormatTime(eventTime.realtimeMs); |
| maybeRecordAudioFormatTime(eventTime.realtimeMs); |
| |
| currentPlaybackState = newPlaybackState; |
| currentPlaybackStateStartTimeMs = eventTime.realtimeMs; |
| if (keepHistory) { |
| playbackStateHistory.add(new EventTimeAndPlaybackState(eventTime, currentPlaybackState)); |
| } |
| } |
| |
| private @PlaybackState int resolveNewPlaybackState() { |
| if (isFinished) { |
| // Keep VIDEO_STATE_ENDED if playback naturally ended (or progressed to next item). |
| 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. |
| return PlaybackStats.PLAYBACK_STATE_SEEKING; |
| } else if (hasFatalError) { |
| return PlaybackStats.PLAYBACK_STATE_FAILED; |
| } else if (!isForeground) { |
| // Before the playback becomes foreground, only report background joining and not started. |
| return startedLoading |
| ? PlaybackStats.PLAYBACK_STATE_JOINING_BACKGROUND |
| : PlaybackStats.PLAYBACK_STATE_NOT_STARTED; |
| } else if (isInterruptedByAd) { |
| return PlaybackStats.PLAYBACK_STATE_INTERRUPTED_BY_AD; |
| } else if (playerPlaybackState == Player.STATE_ENDED) { |
| return PlaybackStats.PLAYBACK_STATE_ENDED; |
| } else if (playerPlaybackState == Player.STATE_BUFFERING) { |
| if (currentPlaybackState == PlaybackStats.PLAYBACK_STATE_NOT_STARTED |
| || currentPlaybackState == PlaybackStats.PLAYBACK_STATE_JOINING_BACKGROUND |
| || currentPlaybackState == PlaybackStats.PLAYBACK_STATE_JOINING_FOREGROUND |
| || currentPlaybackState == PlaybackStats.PLAYBACK_STATE_INTERRUPTED_BY_AD) { |
| return PlaybackStats.PLAYBACK_STATE_JOINING_FOREGROUND; |
| } |
| if (!playWhenReady) { |
| return PlaybackStats.PLAYBACK_STATE_PAUSED_BUFFERING; |
| } |
| return isSuppressed |
| ? PlaybackStats.PLAYBACK_STATE_SUPPRESSED_BUFFERING |
| : PlaybackStats.PLAYBACK_STATE_BUFFERING; |
| } else if (playerPlaybackState == Player.STATE_READY) { |
| if (!playWhenReady) { |
| return PlaybackStats.PLAYBACK_STATE_PAUSED; |
| } |
| return isSuppressed |
| ? PlaybackStats.PLAYBACK_STATE_SUPPRESSED |
| : PlaybackStats.PLAYBACK_STATE_PLAYING; |
| } else if (playerPlaybackState == Player.STATE_IDLE |
| && currentPlaybackState != PlaybackStats.PLAYBACK_STATE_NOT_STARTED) { |
| // This case only applies for calls to player.stop(). All other IDLE cases are handled by |
| // !isForeground, hasFatalError or isSuspended. NOT_STARTED is deliberately ignored. |
| return PlaybackStats.PLAYBACK_STATE_STOPPED; |
| } |
| return currentPlaybackState; |
| } |
| |
| private void maybeUpdateMaxRebufferTimeMs(long nowMs) { |
| if (isRebufferingState(currentPlaybackState)) { |
| long rebufferDurationMs = nowMs - lastRebufferStartTimeMs; |
| if (maxRebufferTimeMs == C.TIME_UNSET || rebufferDurationMs > maxRebufferTimeMs) { |
| maxRebufferTimeMs = rebufferDurationMs; |
| } |
| } |
| } |
| |
| private void maybeUpdateMediaTimeHistory(long realtimeMs, long mediaTimeMs) { |
| if (!keepHistory) { |
| return; |
| } |
| if (currentPlaybackState != PlaybackStats.PLAYBACK_STATE_PLAYING) { |
| if (mediaTimeMs == C.TIME_UNSET) { |
| return; |
| } |
| if (!mediaTimeHistory.isEmpty()) { |
| long previousMediaTimeMs = mediaTimeHistory.get(mediaTimeHistory.size() - 1)[1]; |
| if (previousMediaTimeMs != mediaTimeMs) { |
| mediaTimeHistory.add(new long[] {realtimeMs, previousMediaTimeMs}); |
| } |
| } |
| } |
| mediaTimeHistory.add( |
| mediaTimeMs == C.TIME_UNSET |
| ? guessMediaTimeBasedOnElapsedRealtime(realtimeMs) |
| : new long[] {realtimeMs, mediaTimeMs}); |
| } |
| |
| private long[] guessMediaTimeBasedOnElapsedRealtime(long realtimeMs) { |
| long[] previousKnownMediaTimeHistory = mediaTimeHistory.get(mediaTimeHistory.size() - 1); |
| long previousRealtimeMs = previousKnownMediaTimeHistory[0]; |
| long previousMediaTimeMs = previousKnownMediaTimeHistory[1]; |
| long elapsedMediaTimeEstimateMs = |
| (long) ((realtimeMs - previousRealtimeMs) * currentPlaybackSpeed); |
| long mediaTimeEstimateMs = previousMediaTimeMs + elapsedMediaTimeEstimateMs; |
| return new long[] {realtimeMs, mediaTimeEstimateMs}; |
| } |
| |
| private void maybeUpdateVideoFormat(EventTime eventTime, @Nullable Format newFormat) { |
| if (Util.areEqual(currentVideoFormat, newFormat)) { |
| return; |
| } |
| maybeRecordVideoFormatTime(eventTime.realtimeMs); |
| if (newFormat != null) { |
| if (initialVideoFormatHeight == C.LENGTH_UNSET && newFormat.height != Format.NO_VALUE) { |
| initialVideoFormatHeight = newFormat.height; |
| } |
| if (initialVideoFormatBitrate == C.LENGTH_UNSET && newFormat.bitrate != Format.NO_VALUE) { |
| initialVideoFormatBitrate = newFormat.bitrate; |
| } |
| } |
| currentVideoFormat = newFormat; |
| if (keepHistory) { |
| videoFormatHistory.add(new EventTimeAndFormat(eventTime, currentVideoFormat)); |
| } |
| } |
| |
| private void maybeUpdateAudioFormat(EventTime eventTime, @Nullable Format newFormat) { |
| if (Util.areEqual(currentAudioFormat, newFormat)) { |
| return; |
| } |
| maybeRecordAudioFormatTime(eventTime.realtimeMs); |
| if (newFormat != null |
| && initialAudioFormatBitrate == C.LENGTH_UNSET |
| && newFormat.bitrate != Format.NO_VALUE) { |
| initialAudioFormatBitrate = newFormat.bitrate; |
| } |
| currentAudioFormat = newFormat; |
| if (keepHistory) { |
| audioFormatHistory.add(new EventTimeAndFormat(eventTime, currentAudioFormat)); |
| } |
| } |
| |
| private void maybeRecordVideoFormatTime(long nowMs) { |
| if (currentPlaybackState == PlaybackStats.PLAYBACK_STATE_PLAYING |
| && currentVideoFormat != null) { |
| long mediaDurationMs = (long) ((nowMs - lastVideoFormatStartTimeMs) * currentPlaybackSpeed); |
| if (currentVideoFormat.height != Format.NO_VALUE) { |
| videoFormatHeightTimeMs += mediaDurationMs; |
| videoFormatHeightTimeProduct += mediaDurationMs * currentVideoFormat.height; |
| } |
| if (currentVideoFormat.bitrate != Format.NO_VALUE) { |
| videoFormatBitrateTimeMs += mediaDurationMs; |
| videoFormatBitrateTimeProduct += mediaDurationMs * currentVideoFormat.bitrate; |
| } |
| } |
| lastVideoFormatStartTimeMs = nowMs; |
| } |
| |
| private void maybeRecordAudioFormatTime(long nowMs) { |
| if (currentPlaybackState == PlaybackStats.PLAYBACK_STATE_PLAYING |
| && currentAudioFormat != null |
| && currentAudioFormat.bitrate != Format.NO_VALUE) { |
| long mediaDurationMs = (long) ((nowMs - lastAudioFormatStartTimeMs) * currentPlaybackSpeed); |
| audioFormatTimeMs += mediaDurationMs; |
| audioFormatBitrateTimeProduct += mediaDurationMs * currentAudioFormat.bitrate; |
| } |
| lastAudioFormatStartTimeMs = nowMs; |
| } |
| |
| private static boolean isReadyState(@PlaybackState int state) { |
| return state == PlaybackStats.PLAYBACK_STATE_PLAYING |
| || state == PlaybackStats.PLAYBACK_STATE_PAUSED |
| || state == PlaybackStats.PLAYBACK_STATE_SUPPRESSED; |
| } |
| |
| private static boolean isPausedState(@PlaybackState int state) { |
| return state == PlaybackStats.PLAYBACK_STATE_PAUSED |
| || state == PlaybackStats.PLAYBACK_STATE_PAUSED_BUFFERING; |
| } |
| |
| private static boolean isRebufferingState(@PlaybackState int state) { |
| return state == PlaybackStats.PLAYBACK_STATE_BUFFERING |
| || state == PlaybackStats.PLAYBACK_STATE_PAUSED_BUFFERING |
| || state == PlaybackStats.PLAYBACK_STATE_SUPPRESSED_BUFFERING; |
| } |
| |
| private static boolean isInvalidJoinTransition( |
| @PlaybackState int oldState, @PlaybackState int newState) { |
| if (oldState != PlaybackStats.PLAYBACK_STATE_JOINING_BACKGROUND |
| && oldState != PlaybackStats.PLAYBACK_STATE_JOINING_FOREGROUND |
| && oldState != PlaybackStats.PLAYBACK_STATE_INTERRUPTED_BY_AD) { |
| return false; |
| } |
| return newState != PlaybackStats.PLAYBACK_STATE_JOINING_BACKGROUND |
| && newState != PlaybackStats.PLAYBACK_STATE_JOINING_FOREGROUND |
| && newState != PlaybackStats.PLAYBACK_STATE_INTERRUPTED_BY_AD |
| && newState != PlaybackStats.PLAYBACK_STATE_PLAYING |
| && newState != PlaybackStats.PLAYBACK_STATE_PAUSED |
| && newState != PlaybackStats.PLAYBACK_STATE_SUPPRESSED |
| && newState != PlaybackStats.PLAYBACK_STATE_ENDED; |
| } |
| } |
| } |